1use std::collections::BTreeMap;
8use std::fs;
9use std::path::PathBuf;
10
11use anyhow::{Context, Result, bail, ensure};
12
13use mj_core::config::ImagePullPolicy;
14
15pub use mj_core::targets::*;
16pub const PODMAN_DOCUMENTATION_PATH: &str = "docs/PODMAN.md";
17pub const DOCKER_DOCUMENTATION_PATH: &str = "docs/DOCKER.md";
18
19pub const PODMAN_DOCUMENTATION: &str = include_str!("../docs/PODMAN.md");
26pub const DOCKER_DOCUMENTATION: &str = include_str!("../docs/DOCKER.md");
28
29const PODMAN_MINIMUM_MAJOR_VERSION: u32 = 4;
30
31#[derive(Debug, Clone, Copy, PartialEq, Eq)]
32enum ManagedResourceKind {
33 Container,
34 Ec2Instance,
35}
36
37fn managed_resource_identity_args(kind: ManagedResourceKind, session_id: &str) -> Vec<String> {
39 match kind {
40 ManagedResourceKind::Container => vec![
41 "--label".to_owned(),
42 format!("{SESSION_LABEL}={session_id}"),
43 "--label".to_owned(),
44 format!("{MANAGED_LABEL}=true"),
45 ],
46 ManagedResourceKind::Ec2Instance => vec![
47 "--tag-specifications".to_owned(),
48 format!(
49 "ResourceType=instance,Tags=[{{Key={SESSION_TAG},Value={session_id}}},{{Key={MANAGED_TAG},Value=true}}]"
50 ),
51 ],
52 }
53}
54
55#[derive(Debug, Clone, PartialEq, Eq)]
56pub struct PodmanPreflight {
57 pub version: String,
58 pub warnings: Vec<PodmanPreflightWarning>,
60}
61
62#[derive(Debug, Clone, PartialEq, Eq)]
63pub struct PodmanPreflightWarning {
64 pub detail: String,
65 pub remediation: String,
66}
67
68impl PodmanPreflightWarning {
69 pub fn notice(&self) -> String {
70 format!("{} {}", self.detail, self.remediation)
71 }
72}
73
74#[derive(Debug, Clone, Copy, PartialEq, Eq)]
79enum PodmanHost<'a> {
80 Local,
81 Ssh(&'a SshTarget),
82}
83
84impl PodmanHost<'_> {
85 fn failure(self) -> String {
87 match self {
88 Self::Local => "Podman preflight failed".to_owned(),
89 Self::Ssh(ssh) => format!("Remote Podman preflight failed on {}", ssh.destination),
90 }
91 }
92
93 fn remediation_scope(self) -> String {
95 match self {
96 Self::Local => String::new(),
97 Self::Ssh(ssh) => format!("On {}: ", ssh.destination),
98 }
99 }
100
101 fn command(self, args: &[&str], purpose: &'static str) -> CommandSpec {
102 self.command_owned(args.iter().map(|arg| (*arg).to_owned()).collect(), purpose)
103 }
104
105 fn command_owned(self, args: Vec<String>, purpose: &'static str) -> CommandSpec {
106 match self {
107 Self::Local => {
108 CommandSpec::new(args[0].clone(), args[1..].iter().cloned()).purpose(purpose)
109 }
110 Self::Ssh(ssh) => ssh_validation_command(ssh, args, purpose),
111 }
112 .stage(ProvisionStage::Provisioning)
113 }
114}
115
116pub fn verify_local_podman(executor: &impl CommandExecutor) -> Result<PodmanPreflight> {
121 verify_podman(PodmanHost::Local, executor)
122}
123
124#[derive(Debug, Clone, PartialEq, Eq)]
125pub struct DockerPreflight {
126 pub version: String,
127}
128
129pub fn verify_local_docker(executor: &impl CommandExecutor) -> Result<DockerPreflight> {
134 verify_docker(None, executor)
135}
136
137pub fn verify_ssh_docker(
138 ssh: &SshTarget,
139 executor: &impl CommandExecutor,
140) -> Result<DockerPreflight> {
141 validate_ssh(ssh)?;
142 verify_docker(Some(ssh), executor).with_context(|| {
143 format!(
144 "Docker preflight on {} failed; run docker info on that SSH host",
145 ssh.destination
146 )
147 })
148}
149
150fn verify_docker(
151 ssh: Option<&SshTarget>,
152 executor: &impl CommandExecutor,
153) -> Result<DockerPreflight> {
154 let command = CommandSpec::new(
155 "docker",
156 ["version", "--format", "{{.Server.Version}} {{.Server.Os}}"],
157 )
158 .purpose("check Docker daemon")
159 .stage(ProvisionStage::Provisioning);
160 let command = match ssh {
161 Some(ssh) => command_over_ssh(command, ssh),
162 None => command,
163 };
164 let output = executor
165 .execute(&command)
166 .context("Docker preflight failed: run `docker info` as the user running Mjolnir")?;
167 ensure!(
168 output.status == 0,
169 "Docker preflight failed: `docker version` exited with status {}: {}. Run `docker info` as the user running Mjolnir. See {DOCKER_DOCUMENTATION_PATH}.",
170 output.status,
171 String::from_utf8_lossy(&output.stderr).trim()
172 );
173 let reported = String::from_utf8_lossy(&output.stdout);
174 let mut fields = reported.split_whitespace();
175 let version = fields.next().unwrap_or_default();
176 let os = fields.next().unwrap_or_default();
177 ensure!(
178 !version.is_empty() && os == "linux",
179 "Docker preflight failed: expected a Linux Docker daemon, got {:?}. See {DOCKER_DOCUMENTATION_PATH}.",
180 reported.trim()
181 );
182 Ok(DockerPreflight {
183 version: version.to_owned(),
184 })
185}
186
187pub fn verify_ssh_podman(
192 ssh: &SshTarget,
193 executor: &impl CommandExecutor,
194) -> Result<PodmanPreflight> {
195 let host = PodmanHost::Ssh(ssh);
196 validate_ssh(ssh).map_err(|error| {
197 anyhow::anyhow!(
198 "{}: the configured SSH destination is unusable ({error}). Set a valid `host` (and optional `user`) for this ssh-podman target. See {PODMAN_DOCUMENTATION_PATH}.",
199 host.failure()
200 )
201 })?;
202 let mut preflight = verify_podman(host, executor)?;
203 if let Some(warning) = ssh_podman_linger_warning(ssh, executor) {
204 preflight.warnings.push(warning);
205 }
206 Ok(preflight)
207}
208
209fn verify_podman(host: PodmanHost<'_>, executor: &impl CommandExecutor) -> Result<PodmanPreflight> {
210 let version = execute_podman_preflight(
211 executor,
212 host,
213 &["podman", "--version"],
214 "check Podman version",
215 "Postcondition `podman --version` succeeds with Podman 4.0.0 or newer",
216 "Install or upgrade Podman: Debian/Ubuntu `sudo apt update && sudo apt install -y podman uidmap`; Fedora `sudo dnf install -y podman shadow-utils`.",
217 )?;
218 let version = parse_podman_version(host, &version.stdout)?;
219
220 let rootless = execute_podman_preflight(
221 executor,
222 host,
223 &["podman", "info", "--format", "{{.Host.Security.Rootless}}"],
224 "check rootless Podman mode",
225 "Postcondition `podman info --format '{{.Host.Security.Rootless}}'` prints `true`",
226 "Run Mjolnir as the ordinary user without `sudo`; if a remote Podman connection is configured, unset `CONTAINER_HOST` or select the rootless local connection.",
227 )?;
228 let rootless_output = String::from_utf8_lossy(&rootless.stdout);
229 if rootless_output.trim() != "true" {
230 bail!(
231 "{}: 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}.",
232 host.failure(),
233 rootless_output.trim(),
234 host.remediation_scope(),
235 );
236 }
237
238 let uid_map = execute_podman_preflight(
239 executor,
240 host,
241 &["podman", "unshare", "cat", "/proc/self/uid_map"],
242 "check rootless Podman UID map",
243 "Postcondition `podman unshare cat /proc/self/uid_map` maps container UIDs 0 and 1",
244 "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.",
245 )?;
246 if !valid_rootless_uid_map(&uid_map.stdout) {
247 bail!(
248 "{}: 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}.",
249 host.failure(),
250 host.remediation_scope(),
251 );
252 }
253
254 Ok(PodmanPreflight {
255 version,
256 warnings: Vec::new(),
257 })
258}
259
260fn ssh_podman_linger_warning(
263 ssh: &SshTarget,
264 executor: &impl CommandExecutor,
265) -> Option<PodmanPreflightWarning> {
266 let command = PodmanHost::Ssh(ssh).command(
267 &[
268 "sh",
269 "-c",
270 "loginctl show-user \"$(id -u)\" --property=Linger --value",
271 ],
272 "check remote user lingering",
273 );
274 let output = match executor.execute(&command) {
275 Ok(output) => output,
276 Err(error) => {
277 return Some(linger_unavailable_warning(
278 ssh,
279 format!("the probe could not run: {error}"),
280 ));
281 }
282 };
283 let linger = String::from_utf8_lossy(&output.stdout);
284 match (output.status, linger.trim().to_ascii_lowercase().as_str()) {
285 (0, "yes") => None,
286 (0, "no") => Some(PodmanPreflightWarning {
287 detail: format!(
288 "Remote user lingering is disabled on {}; SSH-Podman sessions may be terminated when the last SSH connection closes.",
289 ssh.destination
290 ),
291 remediation: format!(
292 "On {}, run `sudo loginctl enable-linger \"$(id -un)\"`.",
293 ssh.destination
294 ),
295 }),
296 (status, _) => {
297 let stderr = String::from_utf8_lossy(&output.stderr);
298 let stderr = stderr.trim();
299 let reason = if status == 127 || stderr.contains("loginctl: not found") {
300 "`loginctl` was not found; this host may not use systemd".to_owned()
301 } else if status != 0 {
302 format!("`loginctl` exited with status {status}: {stderr}")
303 } else {
304 format!("`loginctl` returned an unrecognized Linger value {linger:?}")
305 };
306 Some(linger_unavailable_warning(ssh, reason))
307 }
308 }
309}
310
311fn linger_unavailable_warning(ssh: &SshTarget, reason: String) -> PodmanPreflightWarning {
312 PodmanPreflightWarning {
313 detail: format!(
314 "Remote user-manager durability check is unavailable on {} because {reason}. Mjolnir cannot verify whether rootless Podman sessions survive logout.",
315 ssh.destination
316 ),
317 remediation: format!(
318 "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.",
319 ssh.destination
320 ),
321 }
322}
323
324fn execute_podman_preflight(
325 executor: &impl CommandExecutor,
326 host: PodmanHost<'_>,
327 args: &[&str],
328 purpose: &'static str,
329 postcondition: &str,
330 remediation: &str,
331) -> Result<CommandOutput> {
332 let command = host.command(args, purpose);
333 let failure = host.failure();
334 let scope = host.remediation_scope();
335 let output = match executor.execute(&command) {
336 Ok(output) => output,
337 Err(error) => match ssh_transport_failure(host, &error.to_string()) {
338 Some(message) => bail!("{message}"),
339 None => bail!(
340 "{failure}: {postcondition}. {scope}{remediation} See {PODMAN_DOCUMENTATION_PATH}. Underlying error: {error}"
341 ),
342 },
343 };
344 if output.status == SSH_TRANSPORT_EXIT_STATUS
348 && let Some(message) =
349 ssh_transport_failure(host, String::from_utf8_lossy(&output.stderr).trim())
350 {
351 bail!("{message}");
352 }
353 if output.status != 0 {
354 bail!(
355 "{failure}: {postcondition}. {scope}{remediation} See {PODMAN_DOCUMENTATION_PATH}. Podman reported: {}",
356 String::from_utf8_lossy(&output.stderr).trim()
357 );
358 }
359 Ok(output)
360}
361
362fn ssh_transport_failure(host: PodmanHost<'_>, reported: &str) -> Option<String> {
363 let PodmanHost::Ssh(ssh) = host else {
364 return None;
365 };
366 let destination = &ssh.destination;
367 Some(format!(
368 "{}: SSH could not run the probes on {destination}. Verify that `ssh {destination}` succeeds noninteractively from this host. See {PODMAN_DOCUMENTATION_PATH}. ssh reported: {reported}",
369 host.failure()
370 ))
371}
372
373fn parse_podman_version(host: PodmanHost<'_>, stdout: &[u8]) -> Result<String> {
374 let failure = host.failure();
375 let scope = host.remediation_scope();
376 let version = String::from_utf8_lossy(stdout).trim().to_owned();
377 let Some(candidate) = version
378 .split_whitespace()
379 .find(|part| part.as_bytes().first().is_some_and(u8::is_ascii_digit))
380 else {
381 bail!(
382 "{failure}: Postcondition `podman --version` succeeds with Podman 4.0.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}."
383 );
384 };
385 let Some(major) = candidate
386 .split('.')
387 .next()
388 .and_then(|part| part.parse::<u32>().ok())
389 else {
390 bail!(
391 "{failure}: Postcondition `podman --version` succeeds with Podman 4.0.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}."
392 );
393 };
394 if major < PODMAN_MINIMUM_MAJOR_VERSION {
395 bail!(
396 "{failure}: Postcondition `podman --version` succeeds with Podman 4.0.0 or newer was not met (found {candidate}). {scope}Upgrade Podman to 4.0.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}."
397 );
398 }
399 Ok(candidate.to_owned())
400}
401
402fn valid_rootless_uid_map(stdout: &[u8]) -> bool {
403 let mappings = String::from_utf8_lossy(stdout)
404 .lines()
405 .filter_map(|line| {
406 let mut fields = line.split_whitespace();
407 Some((
408 fields.next()?.parse::<u64>().ok()?,
409 fields.next()?.parse::<u64>().ok()?,
410 fields.next()?.parse::<u64>().ok()?,
411 ))
412 })
413 .collect::<Vec<_>>();
414 [0, 1].into_iter().all(|container_id| {
415 mappings.iter().any(|(inside, _outside, length)| {
416 inside
417 .checked_add(*length)
418 .is_some_and(|end| *inside <= container_id && container_id < end)
419 })
420 })
421}
422
423pub fn provision_plan(
426 template: &TargetTemplate,
427 session_id: &str,
428 bundle: &ProjectBundleSpec,
429 additional_mounts: &[AdditionalMount],
430) -> Result<CommandPlan> {
431 bundle.validate()?;
432 if !additional_mounts.is_empty()
433 && !matches!(
434 template,
435 TargetTemplate::LocalPodman(_)
436 | TargetTemplate::LocalDocker(_)
437 | TargetTemplate::AppleContainer(_)
438 | TargetTemplate::SshPodman { .. }
439 | TargetTemplate::SshDocker { .. }
440 )
441 {
442 bail!("additional mounts require a container-backed target");
443 }
444 if let TargetTemplate::SshDocker { ssh, container } = template {
445 validate_ssh(ssh)?;
446 let mut plan = provision_plan(
447 &TargetTemplate::LocalDocker(container.clone()),
448 session_id,
449 bundle,
450 additional_mounts,
451 )?;
452 plan.commands = plan
453 .commands
454 .into_iter()
455 .map(|command| command_over_ssh(command, ssh))
456 .collect();
457 return Ok(plan);
458 }
459 let name = resource_name(session_id)?;
460 let mut commands = Vec::new();
461 match template {
462 TargetTemplate::LocalBare => {
463 bail!("local bare projects must use the existing-project provisioning path")
464 }
465 TargetTemplate::LocalPodman(container) => {
466 validate_container_template(container)?;
467 commands.push(podman_container_run(
468 container,
469 &name,
470 session_id,
471 additional_mounts,
472 None,
473 )?);
474 commands.extend(
475 install_git_plan(ExecutionBoundary::Container {
476 engine: "podman",
477 container_id: &name,
478 })
479 .commands,
480 );
481 commands.extend(clone_commands(bundle, CONTAINER_WORKSPACE, |args| {
482 container_exec("podman", &name, args)
483 }));
484 }
485 TargetTemplate::LocalDocker(container) => {
486 validate_container_template(container)?;
487 commands.push(docker_container_run(
488 container,
489 &name,
490 session_id,
491 additional_mounts,
492 )?);
493 commands.extend(
494 install_git_plan(ExecutionBoundary::Container {
495 engine: "docker",
496 container_id: &name,
497 })
498 .commands,
499 );
500 commands.extend(clone_commands(bundle, CONTAINER_WORKSPACE, |args| {
501 container_exec("docker", &name, args)
502 }));
503 }
504 TargetTemplate::AppleContainer(container) => {
505 validate_container_template(container)?;
506 commands.push(
507 CommandSpec::new("container", ["system", "status"])
508 .purpose("check Apple container service")
509 .stage(ProvisionStage::Provisioning),
510 );
511 commands.extend(apple_image_prepare_commands(container));
512 commands.push(container_run(
513 "container",
514 container,
515 &name,
516 session_id,
517 additional_mounts,
518 )?);
519 commands.extend(
520 install_git_plan(ExecutionBoundary::Container {
521 engine: "container",
522 container_id: &name,
523 })
524 .commands,
525 );
526 commands.extend(clone_commands(bundle, CONTAINER_WORKSPACE, |args| {
527 container_exec("container", &name, args)
528 }));
529 }
530 TargetTemplate::AwsEc2(aws) => {
531 validate_aws(aws)?;
532 let launch_key = if aws.launch_template.starts_with("lt-") {
533 "LaunchTemplateId"
534 } else {
535 "LaunchTemplateName"
536 };
537 let mut launch = format!("{launch_key}={}", aws.launch_template);
538 if let Some(version) = &aws.launch_template_version {
539 launch.push_str(",Version=");
540 launch.push_str(version);
541 }
542 let mut args = vec![
543 "--profile".to_owned(),
544 aws.profile.clone(),
545 "--region".to_owned(),
546 aws.region.clone(),
547 "ec2".to_owned(),
548 "run-instances".to_owned(),
549 "--launch-template".to_owned(),
550 launch,
551 ];
552 if let Some(instance_type) = &aws.instance_type {
553 args.extend(["--instance-type".to_owned(), instance_type.clone()]);
554 }
555 args.extend(managed_resource_identity_args(
556 ManagedResourceKind::Ec2Instance,
557 session_id,
558 ));
559 args.extend(["--output".to_owned(), "json".to_owned()]);
560 commands.push(
561 CommandSpec::new("aws", args)
562 .purpose("launch EC2 session instance")
563 .stage(ProvisionStage::Provisioning)
564 .creates_target(),
565 );
566 }
567 TargetTemplate::SshBare {
568 ssh,
569 workspace_prefix: _,
570 } => {
571 validate_ssh(ssh)?;
572 let workspace = workspace_for(template, session_id)?;
573 commands.push(
574 ssh_command(ssh, ["mkdir", "-p", &workspace])
575 .purpose("create SSH session workspace")
576 .stage(ProvisionStage::Provisioning)
577 .creates_target(),
578 );
579 commands.extend(install_git_plan(ExecutionBoundary::Ssh(ssh)).commands);
580 commands.extend(clone_commands(bundle, &workspace, |args| {
581 ssh_command_owned(ssh, args)
582 }));
583 }
584 TargetTemplate::SshDocker { .. } => unreachable!("handled above"),
585 TargetTemplate::SshPodman { ssh, container } => {
586 validate_ssh(ssh)?;
587 validate_container_template(container)?;
588 commands.push(podman_container_run(
589 container,
590 &name,
591 session_id,
592 additional_mounts,
593 Some(ssh),
594 )?);
595 commands.extend(
596 install_git_plan(ExecutionBoundary::SshContainer {
597 engine: "podman",
598 ssh,
599 container_id: &name,
600 })
601 .commands,
602 );
603 commands.extend(clone_commands(bundle, CONTAINER_WORKSPACE, |args| {
604 let mut remote = vec!["podman".to_owned(), "exec".to_owned(), name.clone()];
605 remote.extend(args);
606 ssh_command_owned(ssh, remote)
607 }));
608 }
609 }
610 Ok(CommandPlan {
611 description: format!("provision Mjolnir session {session_id}"),
612 commands,
613 })
614}
615
616pub fn provision_bare_project_plan(
621 template: &TargetTemplate,
622 session_id: &str,
623 project_directory: &str,
624) -> Result<CommandPlan> {
625 let project = std::path::Path::new(project_directory);
626 validate_bare_project_path(project)?;
627 match template {
628 TargetTemplate::LocalBare => {}
629 TargetTemplate::SshBare { ssh, .. } => {
630 validate_ssh(ssh)?;
631 workspace_for(template, session_id)?;
632 }
633 _ => bail!("raw project directories require a bare target"),
634 }
635 Ok(CommandPlan {
636 description: format!("provision Mjolnir session {session_id}"),
637 commands: Vec::new(),
638 })
639}
640
641pub fn setup_smoke_plan(template: &TargetTemplate, smoke_id: &str) -> Result<CommandPlan> {
646 let name = resource_name(smoke_id)?;
647 let (engine, container, boundary) = match template {
648 TargetTemplate::LocalPodman(container) => ("podman", container, ExecutionBoundary::Direct),
649 TargetTemplate::LocalDocker(container) => ("docker", container, ExecutionBoundary::Direct),
650 TargetTemplate::AppleContainer(container) => {
651 ("container", container, ExecutionBoundary::Direct)
652 }
653 TargetTemplate::SshPodman { ssh, container } => {
654 validate_ssh(ssh)?;
655 ("podman", container, ExecutionBoundary::Ssh(ssh))
656 }
657 TargetTemplate::SshDocker { ssh, container } => {
658 validate_ssh(ssh)?;
659 ("docker", container, ExecutionBoundary::Ssh(ssh))
660 }
661 _ => bail!("setup smoke tests require a local or SSH container target"),
662 };
663 validate_container_template(container)?;
664
665 let mut run = vec![engine.to_owned()];
666 run.extend(container_run_args(
667 engine,
668 container,
669 &name,
670 smoke_id,
671 &[],
672 None,
673 )?);
674 let exec = vec![
675 engine.to_owned(),
676 "exec".to_owned(),
677 "-i".to_owned(),
678 name.clone(),
679 "true".to_owned(),
680 ];
681 let remove = vec![
682 engine.to_owned(),
683 "rm".to_owned(),
684 "--force".to_owned(),
685 name,
686 ];
687
688 Ok(CommandPlan {
689 description: format!("smoke test Mjolnir setup target {smoke_id}"),
690 commands: vec![
691 at_boundary(boundary, run).purpose("create disposable setup container"),
692 at_boundary(boundary, exec).purpose("execute setup smoke command"),
693 at_boundary(boundary, remove).purpose("remove disposable setup container"),
694 ],
695 })
696}
697
698pub fn run_setup_smoke_test(
701 template: &TargetTemplate,
702 smoke_id: &str,
703 executor: &impl CommandExecutor,
704) -> Result<()> {
705 if let TargetTemplate::LocalDocker(container) = template {
706 return run_docker_overlay_smoke_test(container, smoke_id, executor);
707 }
708 if let TargetTemplate::SshDocker { ssh, container } = template {
709 return run_ssh_docker_overlay_smoke_test(ssh, container, smoke_id, executor);
710 }
711 let plan = setup_smoke_plan(template, smoke_id)?;
712 execute_checked(executor, &plan.commands[0])?;
713 let smoke_result = execute_checked(executor, &plan.commands[1]);
714 let cleanup_result = execute_checked(executor, &plan.commands[2]);
715 smoke_result?;
716 cleanup_result
717}
718
719fn run_ssh_docker_overlay_smoke_test(
720 ssh: &SshTarget,
721 container: &ContainerTemplate,
722 smoke_id: &str,
723 executor: &impl CommandExecutor,
724) -> Result<()> {
725 validate_ssh(ssh)?;
726 validate_container_template(container)?;
727 let name = resource_name(smoke_id)?;
728 let prepare = ssh_command(ssh, ["sh", "-c",
729 "set -eu; root=$(mktemp -d /tmp/mj-docker-overlay-smoke.XXXXXXXXXX); printf 'lower\\n' >\"$root/original.txt\"; chmod 777 \"$root\"; chmod 666 \"$root/original.txt\"; printf '%s\\n' \"$root\""])
730 .purpose("create remote Docker OverlayFS smoke source");
731 let output = executor.execute(&prepare)?;
732 ensure!(
733 output.status == 0,
734 "{} failed on {}: {}",
735 prepare.purpose,
736 ssh.destination,
737 String::from_utf8_lossy(&output.stderr)
738 );
739 let lower = String::from_utf8(output.stdout).context("decode remote smoke directory")?;
740 let lower = lower.trim();
741 ensure!(
742 lower.starts_with("/tmp/mj-docker-overlay-smoke.")
743 && !lower.contains(['\n', '\r'])
744 && !lower.contains("/../"),
745 "unexpected remote smoke directory {lower:?}"
746 );
747 let mount = AdditionalMount {
748 source: PathBuf::from(lower),
749 destination: PathBuf::from("/mnt/hel-overlay-smoke"),
750 read_only: false,
751 };
752 let result = (|| {
753 let create = command_over_ssh(
754 docker_container_run(container, &name, smoke_id, &[mount])?,
755 ssh,
756 );
757 execute_checked(executor, &create)?;
758 let probe = command_over_ssh(
759 container_exec("docker", &name, ["sh", "-c", DOCKER_OVERLAY_SMOKE_PROBE]),
760 ssh,
761 )
762 .purpose("verify remote Docker OverlayFS copy-on-write attachment");
763 execute_checked(executor, &probe)?;
764 execute_checked(executor, &ssh_command(ssh, ["sh", "-c",
765 "test \"$(cat \"$1/original.txt\")\" = lower && test ! -e \"$1/container-created.txt\"", "mj-check-smoke-source", lower])
766 .purpose("verify original remote attachment is unchanged"))
767 })();
768 let cleanup = (|| {
769 let plan = close_plan(
770 &TargetLocator::SshDocker {
771 ssh: ssh.clone(),
772 container_id: name,
773 },
774 smoke_id,
775 )?;
776 for command in &plan.commands {
777 execute_checked(executor, command)?;
778 }
779 execute_checked(
781 executor,
782 &ssh_command(ssh, ["rm", "-rf", "--", lower])
783 .purpose("remove remote Docker smoke source"),
784 )
785 })();
786 match (result, cleanup) {
787 (Ok(()), Ok(())) => Ok(()),
788 (Err(error), Ok(())) | (Ok(()), Err(error)) => Err(error),
789 (Err(error), Err(cleanup)) => {
790 Err(error.context(format!("remote smoke cleanup also failed: {cleanup:#}")))
791 }
792 }
793}
794
795const DOCKER_OVERLAY_SMOKE_PROBE: &str = "test \"$(cat /mnt/hel-overlay-smoke/original.txt)\" = lower && printf 'changed\\n' >/mnt/hel-overlay-smoke/original.txt && printf 'created\\n' >/mnt/hel-overlay-smoke/container-created.txt";
796
797fn docker_overlay_smoke_directory() -> Result<tempfile::TempDir> {
800 let parent = if cfg!(target_os = "macos") {
801 dirs::home_dir().context("locate shared home directory for Docker smoke test")?
802 } else {
803 std::env::temp_dir()
804 };
805 tempfile::Builder::new()
806 .prefix(".mj-docker-overlay-smoke-")
807 .tempdir_in(parent)
808 .context("create Docker OverlayFS smoke directory")
809}
810
811fn run_docker_overlay_smoke_test(
812 container: &ContainerTemplate,
813 smoke_id: &str,
814 executor: &impl CommandExecutor,
815) -> Result<()> {
816 validate_container_template(container)?;
817 let lower = docker_overlay_smoke_directory()?;
818 let original = lower.path().join("original.txt");
819 let added = lower.path().join("container-created.txt");
820 fs::write(&original, b"lower\n").context("write Docker OverlayFS smoke source")?;
821 #[cfg(unix)]
822 {
823 use std::os::unix::fs::PermissionsExt;
824 fs::set_permissions(lower.path(), fs::Permissions::from_mode(0o777))?;
826 fs::set_permissions(&original, fs::Permissions::from_mode(0o666))?;
827 }
828 let name = resource_name(smoke_id)?;
829 let mount = AdditionalMount {
830 source: lower.path().to_path_buf(),
831 destination: PathBuf::from("/mnt/hel-overlay-smoke"),
832 read_only: false,
833 };
834 let create = docker_container_run(container, &name, smoke_id, &[mount])?
835 .purpose("create disposable Docker OverlayFS smoke container");
836 let probe = container_exec("docker", &name, ["sh", "-c", DOCKER_OVERLAY_SMOKE_PROBE])
837 .purpose("verify Docker OverlayFS copy-on-write attachment");
838 let cleanup = close_plan(&TargetLocator::LocalDocker { container_id: name }, smoke_id)?
839 .commands
840 .into_iter()
841 .next()
842 .context("Docker OverlayFS smoke cleanup plan is empty")?;
843
844 let smoke_result =
845 execute_checked(executor, &create).and_then(|()| execute_checked(executor, &probe));
846 if let Err(cleanup_error) = execute_checked(executor, &cleanup) {
847 let retained = lower.keep();
849 let cleanup_error = cleanup_error.context(format!(
850 "Docker smoke cleanup failed; retained source at {}",
851 retained.display()
852 ));
853 return match smoke_result {
854 Ok(()) => Err(cleanup_error),
855 Err(error) => Err(error.context(format!("{cleanup_error:#}"))),
856 };
857 }
858 smoke_result?;
859 ensure!(
860 fs::read(&original).context("read Docker OverlayFS smoke source after container write")?
861 == b"lower\n",
862 "Docker OverlayFS smoke test changed its lower source"
863 );
864 ensure!(
865 !added.exists(),
866 "Docker OverlayFS smoke test created a file in its lower source"
867 );
868 Ok(())
869}
870
871fn execute_checked(executor: &impl CommandExecutor, command: &CommandSpec) -> Result<()> {
872 let output = executor.execute(command)?;
873 if output.status != 0 {
874 bail!(
875 "{} failed with status {}: {}",
876 command.purpose,
877 output.status,
878 String::from_utf8_lossy(&output.stderr).trim()
879 );
880 }
881 Ok(())
882}
883
884pub fn provision_on_locator_plan(
886 locator: &TargetLocator,
887 session_id: &str,
888 bundle: &ProjectBundleSpec,
889) -> Result<CommandPlan> {
890 bundle.validate()?;
891 verify_locator(locator, session_id)?;
892 let TargetLocator::AwsEc2 { ssh, workspace, .. } = locator else {
893 bail!("post-launch provisioning is only required for AWS");
894 };
895 let mut commands = vec![
896 ssh_command(ssh, ["mkdir", "-p", workspace])
897 .purpose("create EC2 session workspace")
898 .stage(ProvisionStage::Cloning),
899 ];
900 commands.extend(install_git_plan(ExecutionBoundary::Ssh(ssh)).commands);
901 commands.extend(clone_commands(bundle, workspace, |args| {
902 ssh_command_owned(ssh, args)
903 }));
904 Ok(CommandPlan {
905 description: format!("initialize EC2 session {session_id}"),
906 commands,
907 })
908}
909
910pub fn reconnect_plan(locator: &TargetLocator, session_id: &str) -> Result<CommandPlan> {
911 verify_locator(locator, session_id)?;
912 let root = worker_root(locator, session_id)?;
913 let binary = format!("{root}/hel");
914 let command = match locator {
915 TargetLocator::LocalBare { .. } => {
916 CommandSpec::new(binary, ["worker", "proxy", "--root", root.as_str()])
917 }
918 TargetLocator::LocalPodman { container_id, .. } => container_exec(
919 "podman",
920 container_id,
921 [&binary, "worker", "proxy", "--root", &root],
922 ),
923 TargetLocator::LocalDocker { container_id } => container_exec(
924 "docker",
925 container_id,
926 [&binary, "worker", "proxy", "--root", &root],
927 ),
928 TargetLocator::AppleContainer { container_id } => container_exec(
929 "container",
930 container_id,
931 [&binary, "worker", "proxy", "--root", &root],
932 ),
933 TargetLocator::AwsEc2 { ssh, .. } | TargetLocator::SshBare { ssh, .. } => {
934 ssh_command(ssh, [&binary, "worker", "proxy", "--root", &root])
935 }
936 TargetLocator::SshPodman {
937 ssh, container_id, ..
938 }
939 | TargetLocator::SshDocker { ssh, container_id } => ssh_command(
940 ssh,
941 [
942 locator.container_engine().expect("remote container"),
943 "exec",
944 "-i",
945 container_id,
946 &binary,
947 "worker",
948 "proxy",
949 "--root",
950 &root,
951 ],
952 ),
953 }
954 .purpose("connect to Mjolnir worker")
955 .stage(ProvisionStage::Starting);
956 Ok(CommandPlan {
957 description: format!("reconnect Mjolnir session {session_id}"),
958 commands: vec![command],
959 })
960}
961
962pub fn target_recovery_plan(
967 locator: &TargetLocator,
968 session_id: &str,
969) -> Result<Option<TargetRecoveryPlan>> {
970 verify_locator(locator, session_id)?;
971 if let TargetLocator::SshDocker { ssh, container_id } = locator {
972 let local = target_recovery_plan(
973 &TargetLocator::LocalDocker {
974 container_id: container_id.clone(),
975 },
976 session_id,
977 )?;
978 return Ok(local.map(|plan| TargetRecoveryPlan {
979 exists: command_over_ssh(plan.exists, ssh),
980 inspect: command_over_ssh(plan.inspect, ssh),
981 start: command_over_ssh(plan.start, ssh),
982 session_id: plan.session_id,
983 }));
984 }
985
986 let (exists, inspect, start) = match locator {
987 TargetLocator::LocalPodman { container_id, .. } => (
988 CommandSpec::new("podman", ["container", "exists", container_id])
989 .purpose("check for Mjolnir session container"),
990 CommandSpec::new("podman", ["container", "inspect", container_id])
991 .purpose("inspect Mjolnir session container"),
992 CommandSpec::new("podman", ["start", container_id])
993 .purpose("start stopped Mjolnir session container"),
994 ),
995 TargetLocator::SshDocker { .. } => unreachable!("handled above"),
996 TargetLocator::LocalDocker { container_id } => (
997 CommandSpec::new(
998 "sh",
999 [
1000 "-c",
1001 "docker container inspect \"$1\" >/dev/null 2>&1 && exit 0; docker info >/dev/null 2>&1 && exit 1; exit 125",
1002 "mj-docker-exists",
1003 container_id,
1004 ],
1005 )
1006 .purpose("check for Mjolnir Docker session container"),
1007 CommandSpec::new("docker", ["container", "inspect", container_id])
1008 .purpose("inspect Mjolnir Docker session container"),
1009 CommandSpec::new("docker", ["start", container_id])
1010 .purpose("start stopped Mjolnir Docker session container"),
1011 ),
1012 TargetLocator::SshPodman { ssh, container_id, .. } => (
1013 ssh_command(ssh, ["podman", "container", "exists", container_id])
1014 .purpose("check for remote Mjolnir session container"),
1015 ssh_command(ssh, ["podman", "container", "inspect", container_id])
1016 .purpose("inspect remote Mjolnir session container"),
1017 ssh_command(ssh, ["podman", "start", container_id])
1018 .purpose("start stopped remote Mjolnir session container"),
1019 ),
1020 TargetLocator::LocalBare { .. }
1021 | TargetLocator::AppleContainer { .. }
1022 | TargetLocator::AwsEc2 { .. }
1023 | TargetLocator::SshBare { .. } => return Ok(None),
1024 };
1025 Ok(Some(TargetRecoveryPlan {
1026 exists,
1027 inspect,
1028 start,
1029 session_id: session_id.to_owned(),
1030 }))
1031}
1032
1033pub fn ensure_recovery_target_running(
1037 executor: &impl CommandExecutor,
1038 plan: Option<&TargetRecoveryPlan>,
1039) -> Result<TargetRecoveryOutcome> {
1040 let Some(plan) = plan else {
1041 return Ok(TargetRecoveryOutcome::NotRequired);
1042 };
1043 let existence = executor
1044 .execute(&plan.exists)
1045 .context("check whether container session target exists")?;
1046 match existence.status {
1047 0 => {}
1048 1 => return Ok(TargetRecoveryOutcome::Missing),
1052 _ => {
1053 checked_command_output(&plan.exists, existence)
1054 .context("check whether container session target exists")?;
1055 unreachable!("a successful checked command has status zero");
1056 }
1057 }
1058 let status = inspect_recovery_target(executor, plan)?;
1059 match status.as_str() {
1060 "running" => Ok(TargetRecoveryOutcome::AlreadyRunning),
1061 "created" | "initialized" | "stopped" | "exited" => {
1062 let output = executor.execute(&plan.start)?;
1063 checked_command_output(&plan.start, output)
1064 .context("start confirmed stopped container session target")?;
1065 let after = inspect_recovery_target(executor, plan)
1066 .context("verify container session target after starting it")?;
1067 ensure!(
1068 after == "running",
1069 "container session target reported {after:?} after start"
1070 );
1071 Ok(TargetRecoveryOutcome::Started)
1072 }
1073 "paused" | "removing" | "stopping" | "unknown" => {
1074 bail!("refusing to start container session target in {status:?} state")
1075 }
1076 _ => bail!("container session target reported unexpected state {status:?}"),
1077 }
1078}
1079
1080fn inspect_recovery_target(
1081 executor: &impl CommandExecutor,
1082 plan: &TargetRecoveryPlan,
1083) -> Result<String> {
1084 let output = executor.execute(&plan.inspect)?;
1085 let output = checked_command_output(&plan.inspect, output)
1086 .context("inspect container session target for recovery")?;
1087 let values: Vec<serde_json::Value> =
1088 serde_json::from_slice(&output.stdout).context("parse container target inspection")?;
1089 ensure!(
1090 values.len() == 1,
1091 "container inspection returned {} targets instead of one",
1092 values.len()
1093 );
1094 let target = &values[0];
1095 let labels = target
1096 .pointer("/Config/Labels")
1097 .and_then(serde_json::Value::as_object)
1098 .context("container session target has no ownership labels")?;
1099 ensure!(
1100 labels
1101 .get(MANAGED_LABEL)
1102 .and_then(serde_json::Value::as_str)
1103 == Some("true"),
1104 "refusing to start a container target Mjolnir does not own"
1105 );
1106 ensure!(
1107 labels
1108 .get(SESSION_LABEL)
1109 .and_then(serde_json::Value::as_str)
1110 == Some(plan.session_id.as_str()),
1111 "refusing to start a container target owned by another session"
1112 );
1113 target
1114 .pointer("/State/Status")
1115 .and_then(serde_json::Value::as_str)
1116 .map(str::to_owned)
1117 .context("container session target inspection has no state")
1118}
1119
1120const CGROUP_RESOURCE_USAGE_SCRIPT: &str = r#"
1121for file in memory.current memory.max memory.swap.current memory.swap.max; do
1122 path="/sys/fs/cgroup/$file"
1123 if [ -r "$path" ]; then
1124 printf "%s=%s\n" "$file" "$(cat "$path")"
1125 fi
1126done
1127if [ -r /sys/fs/cgroup/cpu.stat ]; then
1128 before=$(awk '/^usage_usec / { print $2 }' /sys/fs/cgroup/cpu.stat)
1129 sleep 0.25
1130 after=$(awk '/^usage_usec / { print $2 }' /sys/fs/cgroup/cpu.stat)
1131 set -- $(cat /sys/fs/cgroup/cpu.max 2>/dev/null || printf 'max 100000')
1132 if [ "$1" = max ]; then
1133 cores=$(getconf _NPROCESSORS_ONLN 2>/dev/null || printf '1')
1134 else
1135 cores=$(awk -v quota="$1" -v period="$2" 'BEGIN { print quota / period }')
1136 fi
1137 awk -v used="$((after - before))" -v cores="$cores" \
1138 'BEGIN { if (cores > 0) printf "cpu.percent=%.0f\n", used / 250000 / cores * 100 }'
1139fi
1140"#;
1141
1142const HOST_RESOURCE_USAGE_SCRIPT: &str = r#"
1143memory_proc_root=${1:-/proc}
1144read_cpu() { awk '/^cpu / { total=0; for (i=2; i<=NF; i++) total += $i; print total, $5 + $6 }' /proc/stat; }
1145set -- $(read_cpu); total_before=$1; idle_before=$2
1146sleep 0.25
1147set -- $(read_cpu); total_after=$1; idle_after=$2
1148awk -v total="$((total_after - total_before))" -v idle="$((idle_after - idle_before))" \
1149 'BEGIN { if (total > 0) printf "cpu.percent=%.0f\n", (total - idle) * 100 / total }'
1150arc_size=0
1151arc_min=0
1152arcstats="$memory_proc_root/spl/kstat/zfs/arcstats"
1153if [ -r "$arcstats" ]; then
1154 set -- $(awk '
1155 $1 == "c_min" { arc_min = $3 }
1156 $1 == "size" { arc_size = $3 }
1157 END { printf "%.0f %.0f\n", arc_size, arc_min }
1158 ' "$arcstats")
1159 arc_size=$1
1160 arc_min=$2
1161fi
1162awk -v arc_size="$arc_size" -v arc_min="$arc_min" '
1163 /^MemTotal:/ { memory_total = $2 }
1164 /^MemAvailable:/ { memory_available = $2 }
1165 /^SwapTotal:/ { swap_total = $2 }
1166 /^SwapFree:/ { swap_free = $2 }
1167 END {
1168 memory_total *= 1024
1169 memory_available *= 1024
1170 # Like btop, count ARC above its minimum size as reclaimable cache.
1171 if (arc_size > arc_min) memory_available += arc_size - arc_min
1172 if (memory_available > memory_total) memory_available = memory_total
1173 printf "memory.current=%.0f\n", memory_total - memory_available
1174 printf "memory.max=%.0f\n", memory_total
1175 printf "memory.swap.current=%.0f\n", (swap_total - swap_free) * 1024
1176 printf "memory.swap.max=%.0f\n", swap_total * 1024
1177 }
1178' "$memory_proc_root/meminfo"
1179printf 'logical.cores=%s\n' "$(getconf _NPROCESSORS_ONLN 2>/dev/null || nproc)"
1180"#;
1181
1182const AWS_ALLOCATED_CAPACITY_SCRIPT: &str = r#"
1183awk '/^MemTotal:/ { printf "memory.total=%.0f\n", $2 * 1024 }' /proc/meminfo
1184printf 'logical.cores=%s\n' "$(getconf _NPROCESSORS_ONLN 2>/dev/null || nproc)"
1185df -B1 -P -- "$1" | awk 'NR == 2 { print "disk.total=" $2 }'
1186"#;
1187
1188const AWS_SESSION_DISK_USAGE_SCRIPT: &str = r#"
1194usage=$(du -sk "$@") || exit 1
1195printf '%s\n' "$usage" | awk '{ total += $1 * 1024 } END { print total + 0 }'
1196"#;
1197
1198pub fn resource_probe(locator: &TargetLocator, session_id: &str) -> Result<SessionResourceProbe> {
1199 verify_locator(locator, session_id)?;
1200 let (memory, disk) = match locator {
1201 TargetLocator::LocalPodman { container_id, .. } => (
1202 container_exec(
1203 "podman",
1204 container_id,
1205 ["sh", "-c", CGROUP_RESOURCE_USAGE_SCRIPT],
1206 )
1207 .purpose("sample local Podman container resources"),
1208 Some(
1209 CommandSpec::new(
1210 "podman",
1211 [
1212 "container",
1213 "inspect",
1214 "--size",
1215 "--format",
1216 "{{.SizeRw}}",
1217 container_id,
1218 ],
1219 )
1220 .purpose("sample local Podman container writable disk"),
1221 ),
1222 ),
1223 TargetLocator::LocalDocker { container_id } => (
1224 container_exec(
1225 "docker",
1226 container_id,
1227 ["sh", "-c", CGROUP_RESOURCE_USAGE_SCRIPT],
1228 )
1229 .purpose("sample local Docker container resources"),
1230 Some(
1231 CommandSpec::new(
1232 "docker",
1233 [
1234 "container",
1235 "inspect",
1236 "--size",
1237 "--format",
1238 "{{.SizeRw}}",
1239 container_id,
1240 ],
1241 )
1242 .purpose("sample local Docker container writable disk"),
1243 ),
1244 ),
1245 TargetLocator::SshPodman {
1246 ssh, container_id, ..
1247 }
1248 | TargetLocator::SshDocker { ssh, container_id } => (
1249 ssh_command(
1250 ssh,
1251 [
1252 locator.container_engine().expect("remote container"),
1253 "exec",
1254 container_id,
1255 "sh",
1256 "-c",
1257 CGROUP_RESOURCE_USAGE_SCRIPT,
1258 ],
1259 )
1260 .purpose("sample remote container resources"),
1261 Some(
1262 ssh_command(
1263 ssh,
1264 [
1265 locator.container_engine().expect("remote container"),
1266 "container",
1267 "inspect",
1268 "--size",
1269 "--format",
1270 "{{.SizeRw}}",
1271 container_id,
1272 ],
1273 )
1274 .purpose("sample remote container writable disk"),
1275 ),
1276 ),
1277 TargetLocator::AwsEc2 { ssh, workspace, .. } => {
1278 let worker_root = worker_root(locator, session_id)?;
1279 let profile_root = format!(".local/share/hel/profiles/{session_id}");
1280 (
1281 ssh_command(ssh, ["sh", "-c", HOST_RESOURCE_USAGE_SCRIPT])
1282 .purpose("sample EC2 session resources"),
1283 Some(
1284 ssh_command(
1285 ssh,
1286 [
1287 "sh",
1288 "-c",
1289 AWS_SESSION_DISK_USAGE_SCRIPT,
1290 "sh",
1291 workspace.as_str(),
1292 worker_root.as_str(),
1293 profile_root.as_str(),
1294 ],
1295 )
1296 .purpose("sample EC2 session disk"),
1297 ),
1298 )
1299 }
1300 TargetLocator::AppleContainer { container_id } => (
1301 container_exec(
1302 "container",
1303 container_id,
1304 ["sh", "-c", CGROUP_RESOURCE_USAGE_SCRIPT],
1305 )
1306 .purpose("sample Apple container resources"),
1307 None,
1308 ),
1309 TargetLocator::LocalBare { .. } | TargetLocator::SshBare { .. } => {
1310 bail!("resource sampling is unsupported for this target")
1311 }
1312 };
1313 Ok(SessionResourceProbe { memory, disk })
1314}
1315
1316pub fn parse_resource_usage(
1317 memory_output: &[u8],
1318 disk_output: Option<&[u8]>,
1319) -> Result<SessionResourceUsage> {
1320 let mut values = BTreeMap::new();
1321 let memory_text = String::from_utf8_lossy(memory_output);
1322 for line in memory_text.lines() {
1323 let Some((name, value)) = line.split_once('=') else {
1324 continue;
1325 };
1326 values.insert(name, value.trim());
1327 }
1328
1329 let memory_current_bytes = parse_cgroup_counter(
1330 values
1331 .get("memory.current")
1332 .context("resource probe did not expose memory.current")?,
1333 )?
1334 .context("resource probe reported memory.current as unlimited")?;
1335 let memory_limit_bytes = values
1336 .get("memory.max")
1337 .map(|value| parse_cgroup_counter(value))
1338 .transpose()?
1339 .flatten();
1340 let swap_current_bytes = values
1341 .get("memory.swap.current")
1342 .map(|value| parse_cgroup_counter(value))
1343 .transpose()?
1344 .flatten();
1345 let swap_limit_bytes = values
1346 .get("memory.swap.max")
1347 .map(|value| parse_cgroup_counter(value))
1348 .transpose()?
1349 .flatten();
1350 let writable_disk_bytes = disk_output.map(parse_disk_usage).transpose()?;
1351 let cpu_percent = values
1352 .get("cpu.percent")
1353 .map(|value| parse_percent(value))
1354 .transpose()?;
1355
1356 Ok(SessionResourceUsage {
1357 cpu_percent,
1358 memory_current_bytes,
1359 memory_limit_bytes,
1360 swap_current_bytes,
1361 swap_limit_bytes,
1362 writable_disk_bytes,
1363 })
1364}
1365
1366fn parse_disk_usage(output: &[u8]) -> Result<u64> {
1372 let text = String::from_utf8_lossy(output);
1373 let text = text.trim();
1374 text.parse()
1375 .with_context(|| format!("disk usage probe answered {text:?} instead of a byte count"))
1376}
1377
1378pub fn ssh_host_capacity_command(ssh: &SshTarget) -> CommandSpec {
1379 ssh_command(ssh, ["sh", "-c", HOST_RESOURCE_USAGE_SCRIPT])
1380 .purpose("sample deployment host capacity")
1381}
1382
1383pub fn aws_allocated_capacity_command(
1384 locator: &TargetLocator,
1385 session_id: &str,
1386) -> Result<CommandSpec> {
1387 let TargetLocator::AwsEc2 { workspace, .. } = locator else {
1388 bail!("AWS allocated-capacity probes require an EC2 locator");
1389 };
1390 command_on_locator(
1391 locator,
1392 session_id,
1393 vec![
1394 "sh".into(),
1395 "-c".into(),
1396 AWS_ALLOCATED_CAPACITY_SCRIPT.into(),
1397 "sh".into(),
1398 workspace.clone(),
1399 ],
1400 "sample EC2 allocated capacity",
1401 )
1402}
1403
1404pub fn parse_host_capacity(output: &[u8]) -> Result<DeploymentCapacityUsage> {
1405 let values = parse_key_values(output);
1406 let total = parse_required_u64(&values, "memory.max")?;
1407 Ok(DeploymentCapacityUsage {
1408 cpu_percent: Some(parse_percent(required_value(&values, "cpu.percent")?)?),
1409 memory_used_bytes: parse_required_u64(&values, "memory.current")?,
1410 memory_total_bytes: total,
1411 logical_cores: parse_required_u64(&values, "logical.cores")?,
1412 disk_total_bytes: None,
1413 })
1414}
1415
1416pub fn parse_aws_allocated_capacity(output: &[u8]) -> Result<DeploymentCapacityUsage> {
1417 let values = parse_key_values(output);
1418 let memory_total_bytes = parse_required_u64(&values, "memory.total")?;
1419 Ok(DeploymentCapacityUsage {
1420 cpu_percent: None,
1421 memory_used_bytes: 0,
1422 memory_total_bytes,
1423 logical_cores: parse_required_u64(&values, "logical.cores")?,
1424 disk_total_bytes: Some(parse_required_u64(&values, "disk.total")?),
1425 })
1426}
1427
1428fn parse_key_values(output: &[u8]) -> BTreeMap<String, String> {
1429 String::from_utf8_lossy(output)
1430 .lines()
1431 .filter_map(|line| line.split_once('='))
1432 .map(|(key, value)| (key.to_owned(), value.trim().to_owned()))
1433 .collect()
1434}
1435
1436fn required_value<'a>(values: &'a BTreeMap<String, String>, key: &str) -> Result<&'a str> {
1437 values
1438 .get(key)
1439 .map(String::as_str)
1440 .with_context(|| format!("capacity probe did not expose {key}"))
1441}
1442
1443fn parse_required_u64(values: &BTreeMap<String, String>, key: &str) -> Result<u64> {
1444 required_value(values, key)?
1445 .parse()
1446 .with_context(|| format!("capacity probe reported invalid {key}"))
1447}
1448
1449fn parse_percent(value: &str) -> Result<u8> {
1450 let value: f64 = value
1451 .parse()
1452 .with_context(|| format!("invalid percentage {value:?}"))?;
1453 if !value.is_finite() {
1454 bail!("invalid percentage {value:?}");
1455 }
1456 Ok(value.round().clamp(0.0, 100.0) as u8)
1457}
1458
1459fn parse_cgroup_counter(value: &str) -> Result<Option<u64>> {
1460 if value == "max" {
1461 return Ok(None);
1462 }
1463 Ok(Some(value.parse().with_context(|| {
1464 format!("invalid memory counter {value:?}")
1465 })?))
1466}
1467
1468fn worker_daemon_identity_script(worker_root: &str) -> String {
1472 format!(
1473 r#"hel_root={root}
1474hel_match="hel worker run --root $hel_root"
1475hel_match_home="hel worker run --root $HOME/$hel_root"
1476hel_ps() {{
1477 ps -ww "$@" 2>/dev/null || ps "$@" 2>/dev/null
1478}}
1479hel_is_worker() {{
1480 hel_args=$(hel_ps -o args= -p "$1") || return 1
1481 case "$hel_args" in
1482 *"$hel_match"*|*"$hel_match_home"*) return 0 ;;
1483 esac
1484 return 1
1485}}
1486hel_recorded_worker() {{
1487 [ -f "$hel_root/{pid_file}" ] || return 1
1488 hel_pid=$(cat "$hel_root/{pid_file}" 2>/dev/null)
1489 case "$hel_pid" in
1490 '' | *[!0-9]*) return 1 ;;
1491 esac
1492 hel_is_worker "$hel_pid" || return 1
1493 printf '%s\n' "$hel_pid"
1494}}"#,
1495 root = posix_quote(worker_root),
1496 pid_file = mj_core::relay::WORKER_PID_FILE,
1497 )
1498}
1499
1500pub fn worker_daemon_liveness_script(worker_root: &str) -> String {
1504 let mut script = worker_daemon_identity_script(worker_root);
1505 script.push_str(
1506 r#"
1507hel_report_worker_state() {
1508 if [ -S "$hel_root/control.sock" ]; then
1509 printf 'alive\n'
1510 else
1511 printf 'starting\n'
1512 fi
1513}
1514if hel_recorded_worker >/dev/null; then
1515 hel_report_worker_state
1516 exit 0
1517fi
1518while read -r hel_pid hel_args; do
1519 case "$hel_pid" in
1520 '' | *[!0-9]*) continue ;;
1521 esac
1522 [ "$hel_pid" -eq $$ ] && continue
1523 case "$hel_args" in
1524 *"$hel_match"*|*"$hel_match_home"*) hel_report_worker_state; exit 0 ;;
1525 esac
1526done <<MJ_PS
1527$(hel_ps -eo pid=,args=)
1528MJ_PS
1529printf 'dead\n'
1530"#,
1531 );
1532 script
1533}
1534
1535pub fn stop_worker_daemon_script(worker_root: &str) -> String {
1542 let mut script = worker_daemon_identity_script(worker_root);
1543 script.push_str(
1544 r#"
1545hel_signal() {
1546 kill -"$1" -- "-$2" 2>/dev/null && return 0
1547 kill -"$1" "-$2" 2>/dev/null && return 0
1548 kill -"$1" "$2" 2>/dev/null
1549}
1550hel_stop() {
1551 hel_signal TERM "$1" || return 0
1552 hel_waited=0
1553 while [ "$hel_waited" -lt 2 ]; do
1554 kill -0 "$1" 2>/dev/null || return 0
1555 sleep 1
1556 hel_waited=$((hel_waited + 1))
1557 done
1558 kill -0 "$1" 2>/dev/null || return 0
1559 hel_signal KILL "$1" || true
1560 hel_waited=0
1561 while [ "$hel_waited" -lt 3 ]; do
1562 kill -0 "$1" 2>/dev/null || return 0
1563 sleep 1
1564 hel_waited=$((hel_waited + 1))
1565 done
1566}
1567if hel_pid=$(hel_recorded_worker); then
1568 hel_stop "$hel_pid"
1569fi
1570hel_ps -eo pid=,args= | while read -r hel_pid hel_args; do
1571 case "$hel_pid" in
1572 '' | *[!0-9]*) continue ;;
1573 esac
1574 [ "$hel_pid" -eq $$ ] && continue
1575 case "$hel_args" in
1576 *"$hel_match"*|*"$hel_match_home"*) hel_stop "$hel_pid" ;;
1577 esac
1578done
1579hel_left=0
1580while read -r hel_pid hel_args; do
1581 case "$hel_pid" in
1582 '' | *[!0-9]*) continue ;;
1583 esac
1584 [ "$hel_pid" -eq $$ ] && continue
1585 case "$hel_args" in
1586 *"$hel_match"*|*"$hel_match_home"*) hel_left=1 ;;
1587 esac
1588done <<MJ_PS
1589$(hel_ps -eo pid=,args=)
1590MJ_PS
1591if [ "$hel_left" -ne 0 ]; then
1592 echo "worker still running after stop: $hel_root" >&2
1593 exit 1
1594fi
1595"#,
1596 );
1597 script
1598}
1599
1600pub fn clear_relay_state_plan(
1607 locator: &TargetLocator,
1608 session_id: &str,
1609) -> Result<Option<CommandSpec>> {
1610 verify_locator(locator, session_id)?;
1611 let session_worker_root = worker_root(locator, session_id)?;
1612 let script = format!(
1613 "{}\nrm -rf -- {} {}\n",
1614 stop_worker_daemon_script(&session_worker_root),
1615 posix_quote(&format!(
1616 "{session_worker_root}/{}",
1617 mj_core::relay::RELAY_STATE_FILE
1618 )),
1619 posix_quote(&format!(
1620 "{session_worker_root}/{}",
1621 mj_core::relay::RELAY_JOURNAL_DIR
1622 )),
1623 );
1624 Ok(match locator {
1625 TargetLocator::LocalBare { .. } => Some(
1626 CommandSpec::new("sh", ["-c", script.as_str()])
1627 .purpose("stop a leaked local Mjolnir worker and clear its relay state"),
1628 ),
1629 TargetLocator::SshBare { ssh, .. } => Some(
1630 ssh_command(ssh, ["sh", "-c", script.as_str()])
1631 .purpose("stop a leaked remote Mjolnir worker and clear its relay state"),
1632 ),
1633 TargetLocator::LocalPodman { .. }
1634 | TargetLocator::LocalDocker { .. }
1635 | TargetLocator::AppleContainer { .. }
1636 | TargetLocator::SshPodman { .. }
1637 | TargetLocator::SshDocker { .. }
1638 | TargetLocator::AwsEc2 { .. } => None,
1639 })
1640}
1641
1642pub fn close_plan(locator: &TargetLocator, session_id: &str) -> Result<CommandPlan> {
1643 verify_locator(locator, session_id)?;
1644 if let TargetLocator::SshDocker { ssh, container_id } = locator {
1645 let local = close_plan(
1646 &TargetLocator::LocalDocker {
1647 container_id: container_id.clone(),
1648 },
1649 session_id,
1650 )?;
1651 return Ok(CommandPlan {
1652 description: local.description,
1653 commands: local
1654 .commands
1655 .into_iter()
1656 .map(|command| command_over_ssh(command, ssh))
1657 .collect(),
1658 });
1659 }
1660
1661 let session_worker_root = worker_root(locator, session_id)?;
1662 let session_profile_home = format!(".local/share/hel/profiles/{session_id}");
1663 if matches!(
1664 locator,
1665 TargetLocator::LocalPodman { .. } | TargetLocator::SshPodman { .. }
1666 ) {
1667 return podman_cleanup_plan(locator, session_id);
1668 }
1669 let command = match locator {
1670 TargetLocator::LocalBare { .. } => {
1671 let script = format!(
1674 "{}\nrm -rf -- {}\n",
1675 stop_worker_daemon_script(&session_worker_root),
1676 posix_quote(&session_worker_root),
1677 );
1678 CommandSpec::new("sh", ["-c", script.as_str()]).purpose(
1679 "stop the local Mjolnir worker and remove exact local Mjolnir worker state",
1680 )
1681 }
1682 TargetLocator::LocalPodman { .. } => unreachable!("handled above"),
1683 TargetLocator::SshDocker { .. } => unreachable!("handled above"),
1684 TargetLocator::LocalDocker { container_id } => {
1685 let script = r#"status=0
1686helper="$1-mount-init"
1687if identity=$(docker container inspect --format '{{index .Config.Labels "dev.mj.attachment-helper"}}|{{index .Config.Labels "dev.mj.session"}}' "$helper" 2>/dev/null); then
1688 if [ "$identity" = "true|$2" ]; then
1689 docker rm --force "$helper" || status=$?
1690 else
1691 echo 'refusing to remove a foreign Docker attachment helper' >&2
1692 status=2
1693 fi
1694elif ! docker info >/dev/null 2>&1; then
1695 echo 'could not determine whether the Docker attachment helper exists' >&2
1696 status=1
1697fi
1698if identity=$(docker container inspect --format '{{index .Config.Labels "dev.mj.managed"}}|{{index .Config.Labels "dev.mj.session"}}' "$1" 2>/dev/null); then
1699 if [ "$identity" = "true|$2" ]; then
1700 docker rm --force "$1" || status=$?
1701 else
1702 echo 'refusing to remove a Docker container Mjolnir does not own for this session' >&2
1703 status=2
1704 fi
1705elif ! docker info >/dev/null 2>&1; then
1706 echo 'could not determine whether the Docker session container exists' >&2
1707 status=1
1708fi
1709if [ "$status" -eq 0 ]; then
1710 volumes=$(docker volume ls --quiet --filter "label=dev.mj.managed=true" --filter "label=dev.mj.session=$2") || status=$?
1711 if [ "$status" -eq 0 ]; then
1712 backings=
1713 for volume in $volumes; do
1714 backing=$(docker volume inspect --format '{{index .Labels "dev.mj.attachment-backing"}}' "$volume") || { status=$?; continue; }
1715 if [ "$backing" = true ]; then
1716 backings="$backings $volume"
1717 else
1718 docker volume rm --force "$volume" || status=$?
1719 fi
1720 done
1721 if [ "$status" -eq 0 ]; then
1722 for backing in $backings; do docker volume rm --force "$backing" || status=$?; done
1723 fi
1724 fi
1725fi
1726if [ "$status" -eq 0 ]; then
1727 rm -rf -- "$HOME/.cache/mjolnir/git/sessions/$2" || status=$?
1728fi
1729if [ "$status" -eq 0 ]; then
1730 root="$HOME/.cache/mjolnir/docker-overlays/$1"
1731 if [ "$(cat "$root/.hel-session" 2>/dev/null || true)" = "$2" ]; then
1732 case $1 in mj-*|hel-*) rm -rf -- "$root" || status=$? ;; *) status=2 ;; esac
1733 fi
1734fi
1735exit "$status""#;
1736 CommandSpec::new("sh", ["-c", script, "mj-close", container_id, session_id])
1737 .purpose("remove local Docker session container, overlay volumes, and cache state")
1738 }
1739 TargetLocator::AppleContainer { container_id } => {
1740 let script = "status=0; container rm --force \"$1\" || status=$?; rm -rf -- \"$HOME/.cache/mjolnir/git/sessions/$2\"; exit \"$status\"";
1741 CommandSpec::new("sh", ["-c", script, "mj-close", container_id, session_id])
1742 .purpose("remove Apple session container and Git cache snapshot")
1743 }
1744 TargetLocator::AwsEc2 {
1745 profile,
1746 region,
1747 instance_id,
1748 ..
1749 } => {
1750 CommandSpec::new(
1753 "aws",
1754 [
1755 "--profile",
1756 profile,
1757 "--region",
1758 region,
1759 "ec2",
1760 "terminate-instances",
1761 "--instance-ids",
1762 instance_id,
1763 ],
1764 )
1765 .purpose("terminate exact EC2 session instance")
1766 }
1767 TargetLocator::SshBare { ssh, workspace, .. } => {
1768 let script = format!(
1771 "{}\nrm -rf -- {} {} {}\n",
1772 stop_worker_daemon_script(&session_worker_root),
1773 posix_quote(workspace),
1774 posix_quote(&session_worker_root),
1775 posix_quote(&session_profile_home),
1776 );
1777 ssh_command(ssh, ["sh", "-c", script.as_str()]).purpose(
1778 "stop the remote Mjolnir worker and remove exact SSH session workspace and runtime state",
1779 )
1780 }
1781 TargetLocator::SshPodman { .. } => unreachable!("handled above"),
1782 };
1783 Ok(CommandPlan {
1784 description: format!("close Mjolnir session {session_id}"),
1785 commands: vec![command],
1786 })
1787}
1788
1789pub fn borrowed_worker_cleanup_plan(
1792 locator: &TargetLocator,
1793 child_session_id: &str,
1794) -> Result<CommandPlan> {
1795 verify_locator(locator, child_session_id)?;
1796 let worker_root = worker_root(locator, child_session_id)?;
1797 let mut script = stop_worker_daemon_script(&worker_root);
1798 script.push_str(&format!("rm -rf -- {}\n", posix_quote(&worker_root)));
1799 if !matches!(locator, TargetLocator::LocalBare { .. }) {
1800 script.push_str(&format!(
1801 "rm -rf -- {} {}\n",
1802 posix_quote(&format!("/var/lib/hel/profiles/{child_session_id}")),
1803 posix_quote(&format!(".local/share/hel/profiles/{child_session_id}")),
1804 ));
1805 }
1806 let command = command_on_locator(
1807 locator,
1808 child_session_id,
1809 vec!["sh".into(), "-c".into(), script],
1810 "stop a borrowed-target sub-agent and remove its private worker state",
1811 )?;
1812 Ok(CommandPlan {
1813 description: format!("clean up sub-agent worker {child_session_id}"),
1814 commands: vec![command],
1815 })
1816}
1817
1818const PODMAN_CONTAINER_IDENTITY_SCRIPT: &str = r#"set -eu
1819container=$1
1820session=$2
1821if identity=$(podman container inspect --format '{{index .Config.Labels "dev.mj.managed"}}|{{index .Config.Labels "dev.mj.session"}}' "$container" 2>/dev/null); then
1822 [ "$identity" = "true|$session" ] || {
1823 echo 'refusing to operate on a Podman container Mjolnir does not own for this session' >&2
1824 exit 2
1825 }
1826elif ! podman info >/dev/null 2>&1; then
1827 echo 'could not determine whether the Podman session container exists' >&2
1828 exit 1
1829else
1830 exit 0
1831fi
1832"#;
1833
1834pub fn quiesce_plan(locator: &TargetLocator, session_id: &str) -> Result<Option<CommandPlan>> {
1837 verify_locator(locator, session_id)?;
1838 let (ssh, container_id) = match locator {
1839 TargetLocator::LocalPodman { container_id, .. } => (None, container_id),
1840 TargetLocator::SshPodman {
1841 ssh, container_id, ..
1842 } => (Some(ssh), container_id),
1843 _ => return Ok(None),
1844 };
1845 let script = format!(
1846 "{PODMAN_CONTAINER_IDENTITY_SCRIPT}\nif podman container inspect \"$container\" >/dev/null 2>&1; then\n podman stop --time 0 --ignore \"$container\" >/dev/null\n running=$(podman container inspect --format '{{{{.State.Running}}}}' \"$container\")\n [ \"$running\" = false ] || {{ echo 'Podman session container is still running' >&2; exit 1; }}\nfi\n"
1847 );
1848 let command = match ssh {
1849 Some(ssh) => ssh_command(
1850 ssh,
1851 [
1852 "sh",
1853 "-c",
1854 script.as_str(),
1855 "mj-quiesce",
1856 container_id,
1857 session_id,
1858 ],
1859 ),
1860 None => CommandSpec::new(
1861 "sh",
1862 [
1863 "-c",
1864 script.as_str(),
1865 "mj-quiesce",
1866 container_id,
1867 session_id,
1868 ],
1869 ),
1870 }
1871 .purpose("stop exact Podman session container without removing storage")
1872 .stage(ProvisionStage::StoppingTarget);
1873 Ok(Some(CommandPlan {
1874 description: format!("quiesce Mjolnir session {session_id}"),
1875 commands: vec![command],
1876 }))
1877}
1878
1879fn podman_cleanup_plan(locator: &TargetLocator, session_id: &str) -> Result<CommandPlan> {
1880 let (ssh, container_id, workspace_storage) = match locator {
1881 TargetLocator::LocalPodman {
1882 container_id,
1883 workspace_storage,
1884 } => (None, container_id, workspace_storage),
1885 TargetLocator::SshPodman {
1886 ssh,
1887 container_id,
1888 workspace_storage,
1889 } => (Some(ssh), container_id, workspace_storage),
1890 _ => unreachable!("Podman cleanup requires a Podman locator"),
1891 };
1892 let remove_container_script = format!(
1893 "{PODMAN_CONTAINER_IDENTITY_SCRIPT}\npodman rm --force --ignore \"$container\" >/dev/null\n"
1894 );
1895 let at_host = |args: Vec<String>| match ssh {
1896 Some(ssh) => ssh_command_owned(ssh, args),
1897 None => {
1898 let mut args = args;
1899 CommandSpec::new(args.remove(0), args)
1900 }
1901 };
1902 let mut commands = vec![
1903 at_host(vec![
1904 "sh".to_owned(),
1905 "-c".to_owned(),
1906 remove_container_script,
1907 "mj-remove-container".to_owned(),
1908 container_id.clone(),
1909 session_id.to_owned(),
1910 ])
1911 .purpose("remove exact stopped Podman session container")
1912 .stage(ProvisionStage::RemovingContainer),
1913 ];
1914 match workspace_storage {
1915 PodmanWorkspaceLocator::ContainerLayer => {}
1916 PodmanWorkspaceLocator::Volume { name } => {
1917 let script = r#"set -eu
1918volume=$1
1919session=$2
1920if identity=$(podman volume inspect --format '{{index .Labels "dev.mj.managed"}}|{{index .Labels "dev.mj.session"}}' "$volume" 2>/dev/null); then
1921 [ "$identity" = "true|$session" ] || {
1922 echo 'refusing to remove a Podman volume Mjolnir does not own for this session' >&2
1923 exit 2
1924 }
1925 podman volume rm --force "$volume" >/dev/null
1926elif ! podman info >/dev/null 2>&1; then
1927 echo 'could not determine whether the Podman workspace volume exists' >&2
1928 exit 1
1929fi
1930"#;
1931 commands.push(
1932 at_host(vec![
1933 "sh".to_owned(),
1934 "-c".to_owned(),
1935 script.to_owned(),
1936 "mj-remove-volume".to_owned(),
1937 name.clone(),
1938 session_id.to_owned(),
1939 ])
1940 .purpose("remove exact Podman session workspace volume")
1941 .stage(ProvisionStage::RemovingStorage),
1942 );
1943 }
1944 PodmanWorkspaceLocator::HostPath {
1945 helper, resource, ..
1946 } => {
1947 let helper = join_remote_command(helper);
1948 let script = format!(
1949 r#"set -eu
1950resource=$1
1951state=$({helper} status "$resource")
1952case $state in
1953 present) {helper} destroy "$resource" ;;
1954 absent) ;;
1955 *) echo "workspace helper returned invalid status $state for $resource" >&2; exit 1 ;;
1956esac
1957[ "$({helper} status "$resource")" = absent ] || {{
1958 echo "workspace helper did not destroy $resource" >&2
1959 exit 1
1960}}
1961"#
1962 );
1963 commands.push(
1964 at_host(vec![
1965 "sh".to_owned(),
1966 "-c".to_owned(),
1967 script,
1968 "mj-remove-host-workspace".to_owned(),
1969 resource.clone(),
1970 ])
1971 .purpose("remove exact helper-managed Podman session workspace")
1972 .stage(ProvisionStage::RemovingStorage),
1973 );
1974 }
1975 }
1976 commands.push(
1977 at_host(vec![
1978 "rm".to_owned(),
1979 "-rf".to_owned(),
1980 "--".to_owned(),
1981 format!(".cache/mjolnir/git/sessions/{session_id}"),
1982 ])
1983 .purpose("remove Podman session Git cache snapshot")
1984 .stage(ProvisionStage::CleaningCache),
1985 );
1986 Ok(CommandPlan {
1987 description: format!("clean up stopped Mjolnir session {session_id}"),
1988 commands,
1989 })
1990}
1991
1992pub fn cleanup_target_is_confirmed_absent(
1999 locator: &TargetLocator,
2000 session_id: &str,
2001 executor: &impl CommandExecutor,
2002) -> Result<bool> {
2003 verify_locator(locator, session_id)?;
2004 let (command, status_is_answer) = match locator {
2005 TargetLocator::AppleContainer { .. } => (
2006 CommandSpec::new("container", ["list", "--all", "--quiet"])
2007 .purpose("confirm exact Apple session container is absent"),
2008 false,
2009 ),
2010 TargetLocator::LocalDocker { container_id } | TargetLocator::SshDocker { container_id, .. } => (
2011 CommandSpec::new(
2012 "sh",
2013 [
2014 "-c",
2015 "if docker container inspect \"$1\" >/dev/null 2>&1; then exit 1; fi; docker info >/dev/null 2>&1 || exit 2; test -z \"$(docker volume ls --quiet --filter label=dev.mj.managed=true --filter label=dev.mj.session=$2)\"",
2016 "hel-confirm-absent",
2017 container_id,
2018 session_id,
2019 ],
2020 )
2021 .purpose("confirm exact Docker session resources are absent"),
2022 true,
2023 ),
2024 TargetLocator::LocalPodman {
2025 container_id,
2026 workspace_storage,
2027 } => (
2028 podman_absence_command(None, container_id, workspace_storage, session_id),
2029 true,
2030 ),
2031 TargetLocator::SshPodman {
2032 ssh,
2033 container_id,
2034 workspace_storage,
2035 } => (
2036 podman_absence_command(Some(ssh), container_id, workspace_storage, session_id),
2037 true,
2038 ),
2039 _ => return Ok(false),
2040 };
2041 let command = match locator {
2042 TargetLocator::SshDocker { ssh, .. } => command_over_ssh(command, ssh),
2043 _ => command,
2044 };
2045 let output = executor.execute(&command)?;
2046 if status_is_answer {
2047 return match output.status {
2048 0 => Ok(true),
2049 1 => Ok(false),
2050 _ => bail!(
2051 "{} failed with status {}: {}",
2052 command.purpose,
2053 output.status,
2054 String::from_utf8_lossy(&output.stderr)
2055 ),
2056 };
2057 }
2058 if output.status != 0 {
2059 bail!(
2060 "{} failed with status {}: {}",
2061 command.purpose,
2062 output.status,
2063 String::from_utf8_lossy(&output.stderr)
2064 );
2065 }
2066 let listed = String::from_utf8(output.stdout).context("decode Apple container list")?;
2067 let TargetLocator::AppleContainer { container_id } = locator else {
2068 unreachable!("engine selected from locator")
2069 };
2070 Ok(!listed.lines().any(|id| id.trim() == container_id))
2071}
2072
2073fn podman_absence_command(
2074 ssh: Option<&SshTarget>,
2075 container_id: &str,
2076 workspace_storage: &PodmanWorkspaceLocator,
2077 session_id: &str,
2078) -> CommandSpec {
2079 let storage_check = match workspace_storage {
2080 PodmanWorkspaceLocator::ContainerLayer => "exit 0".to_owned(),
2081 PodmanWorkspaceLocator::Volume { .. } => r#"podman volume exists "$3"
2082case $? in
2083 0) exit 1 ;;
2084 1) exit 0 ;;
2085 *) exit 2 ;;
2086esac"#
2087 .to_owned(),
2088 PodmanWorkspaceLocator::HostPath { helper, .. } => {
2089 let helper = join_remote_command(helper);
2090 format!(
2091 r#"state=$({helper} status "$3") || exit 2
2092case $state in
2093 absent) exit 0 ;;
2094 present) exit 1 ;;
2095 *) exit 2 ;;
2096esac"#
2097 )
2098 }
2099 };
2100 let script = format!(
2101 r#"podman container exists "$1"
2102case $? in
2103 0) exit 1 ;;
2104 1) ;;
2105 *) exit 2 ;;
2106esac
2107{storage_check}"#
2108 );
2109 let storage = match workspace_storage {
2110 PodmanWorkspaceLocator::ContainerLayer => "-",
2111 PodmanWorkspaceLocator::Volume { name } => name,
2112 PodmanWorkspaceLocator::HostPath { resource, .. } => resource,
2113 };
2114 let args = vec![
2115 "sh".to_owned(),
2116 "-c".to_owned(),
2117 script,
2118 "mj-confirm-podman-absent".to_owned(),
2119 container_id.to_owned(),
2120 session_id.to_owned(),
2121 storage.to_owned(),
2122 ];
2123 match ssh {
2124 Some(ssh) => ssh_command_owned(ssh, args),
2125 None => CommandSpec::new(args[0].clone(), args[1..].iter().cloned()),
2126 }
2127 .purpose("confirm exact Podman session resources are absent")
2128}
2129
2130#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2131pub enum ExecutionBoundary<'a> {
2132 Direct,
2133 Container {
2134 engine: &'a str,
2135 container_id: &'a str,
2136 },
2137 Ssh(&'a SshTarget),
2138 SshContainer {
2139 engine: &'a str,
2140 ssh: &'a SshTarget,
2141 container_id: &'a str,
2142 },
2143}
2144
2145#[derive(Debug, Clone, PartialEq, Eq)]
2146pub struct HarnessProbe<'a> {
2147 pub executable: &'a str,
2148 pub version_args: &'a [&'a str],
2149 pub bridge_executable: Option<&'a str>,
2150}
2151
2152pub fn bootstrap_probe_plan(
2156 boundary: ExecutionBoundary<'_>,
2157 harness: HarnessProbe<'_>,
2158) -> Result<CommandPlan> {
2159 validate_executable(harness.executable)?;
2160 let mut commands = vec![
2161 at_boundary(
2162 boundary,
2163 std::iter::once(harness.executable)
2164 .chain(harness.version_args.iter().copied())
2165 .map(str::to_owned)
2166 .collect(),
2167 )
2168 .purpose("probe harness version"),
2169 ];
2170 if let Some(bridge) = harness.bridge_executable {
2171 validate_executable(bridge)?;
2172 commands.push(
2173 at_boundary(boundary, vec![bridge.to_owned(), "--version".to_owned()])
2174 .purpose("probe ACP bridge version"),
2175 );
2176 }
2177 commands.push(
2178 at_boundary(boundary, vec!["git".to_owned(), "--version".to_owned()]).purpose("probe Git"),
2179 );
2180 Ok(CommandPlan {
2181 description: "probe reusable target tools".to_owned(),
2182 commands,
2183 })
2184}
2185
2186pub fn install_git_plan(boundary: ExecutionBoundary<'_>) -> CommandPlan {
2189 let managed_container = matches!(
2190 boundary,
2191 ExecutionBoundary::Container { .. } | ExecutionBoundary::SshContainer { .. }
2192 );
2193 let script = if managed_container {
2194 "set -eu; if ! command -v git >/dev/null 2>&1 || ! command -v gh >/dev/null 2>&1; then SUDO=''; if [ \"$(id -u)\" != 0 ]; then command -v sudo >/dev/null 2>&1 && sudo -n true || { echo 'Git and GitHub CLI installation requires root or passwordless sudo' >&2; exit 1; }; SUDO='sudo -n'; fi; if command -v apt-get >/dev/null 2>&1; then $SUDO apt-get update; $SUDO apt-get install -y git gh ca-certificates curl; elif command -v dnf >/dev/null 2>&1; then $SUDO dnf install -y git gh ca-certificates curl; elif command -v yum >/dev/null 2>&1; then $SUDO yum install -y git gh ca-certificates curl; elif command -v apk >/dev/null 2>&1; then $SUDO apk add --no-cache git github-cli ca-certificates curl; else echo 'Unsupported package manager; install Git and GitHub CLI in the image' >&2; exit 1; fi; fi; git config --global credential.https://github.com.helper '!gh auth git-credential'; git config --global credential.https://gist.github.com.helper '!gh auth git-credential'"
2195 } else {
2196 "set -eu; if command -v git >/dev/null 2>&1; then exit 0; fi; SUDO=''; if [ \"$(id -u)\" != 0 ]; then command -v sudo >/dev/null 2>&1 && sudo -n true || { echo 'Git installation requires root or passwordless sudo' >&2; exit 1; }; SUDO='sudo -n'; fi; if command -v apt-get >/dev/null 2>&1; then $SUDO apt-get update; $SUDO apt-get install -y git ca-certificates curl; elif command -v dnf >/dev/null 2>&1; then $SUDO dnf install -y git ca-certificates curl; elif command -v yum >/dev/null 2>&1; then $SUDO yum install -y git ca-certificates curl; elif command -v apk >/dev/null 2>&1; then $SUDO apk add --no-cache git ca-certificates curl; else echo 'Unsupported package manager; install Git manually' >&2; exit 1; fi"
2197 };
2198 CommandPlan {
2199 description: "install missing Git".to_owned(),
2200 commands: vec![
2201 at_boundary(
2202 boundary,
2203 vec!["sh".to_owned(), "-c".to_owned(), script.to_owned()],
2204 )
2205 .purpose("install Git")
2206 .stage(ProvisionStage::Cloning),
2207 ],
2208 }
2209}
2210
2211const BUNDLE_REPOSITORIES_PARALLEL_GROUP: u32 = 1;
2215
2216fn clone_commands(
2217 bundle: &ProjectBundleSpec,
2218 workspace: &str,
2219 wrap: impl Fn(Vec<String>) -> CommandSpec,
2220) -> Vec<CommandSpec> {
2221 let mut commands = vec![
2222 wrap(vec![
2223 "mkdir".to_owned(),
2224 "-p".to_owned(),
2225 workspace.to_owned(),
2226 ])
2227 .purpose("create bundle workspace")
2228 .stage(ProvisionStage::Cloning),
2229 ];
2230 for repository in &bundle.repositories {
2231 let destination = format!("{workspace}/{}", repository.destination);
2232 let url = repository
2233 .url
2234 .as_ref()
2235 .expect("validated network repository");
2236 let mut args = vec!["git".to_owned(), "clone".to_owned()];
2237 for push_url in &repository.push_urls {
2238 args.extend([
2239 "--config".into(),
2240 format!("remote.origin.pushurl={push_url}"),
2241 ]);
2242 }
2243 if let Some(reference) = &repository.reference {
2244 args.extend(["--reference-if-able".to_owned(), reference.clone()]);
2245 }
2246 args.push("--".to_owned());
2247 args.push(url.clone());
2248 args.push(destination);
2249 commands.push(
2250 wrap(args)
2251 .purpose(format!("clone {}", repository.destination))
2252 .stage(ProvisionStage::Cloning)
2253 .parallel_group(BUNDLE_REPOSITORIES_PARALLEL_GROUP),
2254 );
2255 }
2256 commands
2257}
2258
2259fn container_run(
2260 engine: &str,
2261 template: &ContainerTemplate,
2262 name: &str,
2263 session_id: &str,
2264 additional_mounts: &[AdditionalMount],
2265) -> Result<CommandSpec> {
2266 Ok(CommandSpec::new(
2267 engine,
2268 container_run_args(engine, template, name, session_id, additional_mounts, None)?,
2269 )
2270 .purpose("start session container")
2271 .stage(ProvisionStage::Provisioning)
2272 .creates_target())
2273}
2274
2275const PODMAN_VOLUME_RUN_SCRIPT: &str = r#"set -eu
2276session=$1
2277container=$2
2278volume=$3
2279shift 3
2280cleanup() {
2281 status=$?
2282 trap - EXIT HUP INT TERM
2283 if [ "$status" -ne 0 ]; then
2284 if identity=$(podman container inspect --format '{{index .Config.Labels "dev.mj.managed"}}|{{index .Config.Labels "dev.mj.session"}}' "$container" 2>/dev/null) && [ "$identity" = "true|$session" ]; then
2285 podman rm --force "$container" >/dev/null 2>&1 || true
2286 fi
2287 if identity=$(podman volume inspect --format '{{index .Labels "dev.mj.managed"}}|{{index .Labels "dev.mj.session"}}' "$volume" 2>/dev/null) && [ "$identity" = "true|$session" ]; then
2288 podman volume rm --force "$volume" >/dev/null 2>&1 || true
2289 fi
2290 fi
2291 exit "$status"
2292}
2293trap cleanup EXIT
2294trap 'exit 130' HUP INT TERM
2295if identity=$(podman volume inspect --format '{{index .Labels "dev.mj.managed"}}|{{index .Labels "dev.mj.session"}}' "$volume" 2>/dev/null); then
2296 [ "$identity" = "true|$session" ] || {
2297 echo "refusing foreign Podman volume $volume" >&2
2298 exit 1
2299 }
2300else
2301 podman info >/dev/null
2302 podman volume create --label "dev.mj.managed=true" --label "dev.mj.session=$session" "$volume" >/dev/null
2303 identity=$(podman volume inspect --format '{{index .Labels "dev.mj.managed"}}|{{index .Labels "dev.mj.session"}}' "$volume")
2304 [ "$identity" = "true|$session" ] || {
2305 echo "Podman volume $volume does not carry the expected Mjolnir identity" >&2
2306 exit 1
2307 }
2308fi
2309"$@"
2310"#;
2311
2312fn podman_host_helper_run_script(helper: &[String]) -> String {
2313 let helper = join_remote_command(helper);
2314 format!(
2315 r#"set -eu
2316session=$1
2317container=$2
2318resource=$3
2319shift 3
2320cleanup() {{
2321 status=$?
2322 trap - EXIT HUP INT TERM
2323 if [ "$status" -ne 0 ]; then
2324 if identity=$(podman container inspect --format '{{{{index .Config.Labels "dev.mj.managed"}}}}|{{{{index .Config.Labels "dev.mj.session"}}}}' "$container" 2>/dev/null) && [ "$identity" = "true|$session" ]; then
2325 podman rm --force "$container" >/dev/null 2>&1 || true
2326 fi
2327 {helper} destroy "$resource" >/dev/null 2>&1 || true
2328 fi
2329 exit "$status"
2330}}
2331trap cleanup EXIT
2332trap 'exit 130' HUP INT TERM
2333state=$({helper} status "$resource")
2334case $state in
2335 absent) {helper} create "$resource" ;;
2336 present) ;;
2337 *) echo "workspace helper returned invalid status $state for $resource" >&2; exit 1 ;;
2338esac
2339[ "$({helper} status "$resource")" = present ] || {{
2340 echo "workspace helper did not create $resource" >&2
2341 exit 1
2342}}
2343"$@"
2344"#
2345 )
2346}
2347
2348fn podman_container_run(
2349 template: &ContainerTemplate,
2350 name: &str,
2351 session_id: &str,
2352 additional_mounts: &[AdditionalMount],
2353 ssh: Option<&SshTarget>,
2354) -> Result<CommandSpec> {
2355 let workspace = podman_workspace_locator(template, session_id)?;
2356 let run_args = container_run_args(
2357 "podman",
2358 template,
2359 name,
2360 session_id,
2361 additional_mounts,
2362 Some(&workspace),
2363 )?;
2364 let mut wrapped = match &workspace {
2365 PodmanWorkspaceLocator::ContainerLayer => {
2366 let mut command = vec!["podman".to_owned()];
2367 command.extend(run_args);
2368 command
2369 }
2370 PodmanWorkspaceLocator::Volume { name: volume } => {
2371 let mut command = vec![
2372 "sh".to_owned(),
2373 "-c".to_owned(),
2374 PODMAN_VOLUME_RUN_SCRIPT.to_owned(),
2375 "mj-podman-run".to_owned(),
2376 session_id.to_owned(),
2377 name.to_owned(),
2378 volume.clone(),
2379 "podman".to_owned(),
2380 ];
2381 command.extend(run_args);
2382 command
2383 }
2384 PodmanWorkspaceLocator::HostPath {
2385 helper, resource, ..
2386 } => {
2387 let mut command = vec![
2388 "sh".to_owned(),
2389 "-c".to_owned(),
2390 podman_host_helper_run_script(helper),
2391 "mj-podman-run".to_owned(),
2392 session_id.to_owned(),
2393 name.to_owned(),
2394 resource.clone(),
2395 "podman".to_owned(),
2396 ];
2397 command.extend(run_args);
2398 command
2399 }
2400 };
2401 let command = match ssh {
2402 Some(ssh) => ssh_command_owned(ssh, wrapped),
2403 None => {
2404 let program = wrapped.remove(0);
2405 CommandSpec::new(program, wrapped)
2406 }
2407 };
2408 let purpose = match (&workspace, ssh) {
2409 (PodmanWorkspaceLocator::ContainerLayer, Some(_)) => "start remote Podman container",
2410 (PodmanWorkspaceLocator::ContainerLayer, None) => "start session container",
2411 (_, Some(_)) => "start remote Podman container with isolated workspace storage",
2412 (_, None) => "start Podman container with isolated workspace storage",
2413 };
2414 Ok(command
2415 .purpose(purpose)
2416 .stage(ProvisionStage::Provisioning)
2417 .creates_target())
2418}
2419
2420const DOCKER_OVERLAY_RUN_SCRIPT: &str = r#"set -eu
2421session=$1
2422container=$2
2423image=$3
2424pull=$4
2425shift 4
2426helper="$container-mount-init"
2427volumes=
2428backings=
2429remove_helper() {
2430 if identity=$(docker container inspect --format '{{index .Config.Labels "dev.mj.attachment-helper"}}|{{index .Config.Labels "dev.mj.session"}}' "$helper" 2>/dev/null); then
2431 [ "$identity" = "true|$session" ] || {
2432 echo "refusing foreign Docker attachment helper $helper" >&2
2433 return 1
2434 }
2435 docker rm --force "$helper" >/dev/null
2436 else
2437 docker info >/dev/null
2438 fi
2439}
2440cleanup() {
2441 status=$?
2442 trap - EXIT HUP INT TERM
2443 if [ "$status" -ne 0 ]; then
2444 released=true
2445 remove_helper || released=false
2446 if identity=$(docker container inspect --format '{{index .Config.Labels "dev.mj.managed"}}|{{index .Config.Labels "dev.mj.session"}}' "$container" 2>/dev/null); then
2447 if [ "$identity" = "true|$session" ]; then
2448 docker rm --force "$container" >/dev/null || released=false
2449 else
2450 released=false
2451 fi
2452 elif ! docker info >/dev/null 2>&1; then
2453 released=false
2454 fi
2455 if [ "$released" = true ]; then
2456 for volume in $volumes; do
2457 docker volume rm --force "$volume" >/dev/null || released=false
2458 done
2459 fi
2460 if [ "$released" = true ]; then
2461 for backing in $backings; do
2462 docker volume rm --force "$backing" >/dev/null || released=false
2463 done
2464 fi
2465 if [ "$released" != true ]; then
2466 echo "Docker attachment cleanup failed; retained backing storage for session $session" >&2
2467 fi
2468 fi
2469 exit "$status"
2470}
2471trap cleanup EXIT
2472trap 'exit 130' HUP INT TERM
2473owned_volume() {
2474 identity=$(docker volume inspect --format '{{index .Labels "dev.mj.managed"}}|{{index .Labels "dev.mj.session"}}' "$1")
2475 [ "$identity" = "true|$session" ] || {
2476 echo "refusing foreign Docker volume $1" >&2
2477 return 1
2478 }
2479}
2480remove_helper
2481while [ "$1" != -- ]; do
2482 ordinal=$1
2483 source=$2
2484 volume=$3
2485 shift 3
2486 backing="$volume-backing"
2487 if docker volume inspect "$volume" >/dev/null 2>&1; then
2488 owned_volume "$volume"
2489 owned_volume "$backing"
2490 volumes="$volumes $volume"
2491 backings="$backings $backing"
2492 continue
2493 fi
2494 if ! docker volume inspect "$backing" >/dev/null 2>&1; then
2495 docker volume create --driver local \
2496 --label "dev.mj.managed=true" --label "dev.mj.session=$session" \
2497 --label "dev.mj.attachment-backing=true" "$backing" >/dev/null
2498 fi
2499 owned_volume "$backing"
2500 backings="$backings $backing"
2501 docker run --name "$helper" --pull="$pull" --network none --user 0 \
2502 --label "dev.mj.attachment-helper=true" --label "dev.mj.session=$session" \
2503 --volume "$backing:/mj-attachment" \
2504 --mount "type=bind,source=$source,target=/mj-source,readonly" \
2505 --entrypoint sh "$image" -c '
2506 set -eu
2507 mkdir -p /mj-attachment/upper /mj-attachment/work
2508 chown "$(stat -c %u:%g /mj-source)" /mj-attachment/upper
2509 chmod "$(stat -c %a /mj-source)" /mj-attachment/upper
2510 ' >/dev/null
2511 remove_helper
2512 root=$(docker volume inspect --format '{{.Mountpoint}}' "$backing")
2513 case $root in /*) ;; *) echo "invalid Docker backing volume mountpoint: $root" >&2; exit 1 ;; esac
2514 upper="$root/upper"
2515 work="$root/work"
2516 docker volume create \
2517 --driver local \
2518 --label "dev.mj.managed=true" \
2519 --label "dev.mj.session=$session" \
2520 --opt type=overlay \
2521 --opt device=overlay \
2522 --opt "o=lowerdir=$source,upperdir=$upper,workdir=$work" \
2523 "$volume" >/dev/null
2524 owned_volume "$volume"
2525 volumes="$volumes $volume"
2526done
2527shift
2528"$@"
2529"#;
2530
2531fn docker_overlay_volume_name(container_name: &str, ordinal: usize) -> String {
2532 format!("{container_name}-mount-{ordinal}")
2533}
2534
2535fn docker_pull_policy(template: &ContainerTemplate) -> &'static str {
2536 match template.pull_policy.at_launch(&template.image) {
2537 ImagePullPolicy::Auto => unreachable!("at_launch resolves auto"),
2538 ImagePullPolicy::Always | ImagePullPolicy::Newer => "always",
2539 ImagePullPolicy::Missing => "missing",
2540 ImagePullPolicy::Never => "never",
2541 }
2542}
2543
2544fn docker_container_run(
2545 template: &ContainerTemplate,
2546 name: &str,
2547 session_id: &str,
2548 additional_mounts: &[AdditionalMount],
2549) -> Result<CommandSpec> {
2550 let run_args = container_run_args(
2551 "docker",
2552 template,
2553 name,
2554 session_id,
2555 additional_mounts,
2556 None,
2557 )?;
2558 let writable = additional_mounts
2559 .iter()
2560 .enumerate()
2561 .filter(|(_, mount)| !mount.read_only)
2562 .collect::<Vec<_>>();
2563 if writable.is_empty() {
2564 return container_run("docker", template, name, session_id, additional_mounts);
2565 }
2566 let mut args = vec![
2567 "-c".to_owned(),
2568 DOCKER_OVERLAY_RUN_SCRIPT.to_owned(),
2569 "hel-docker-run".to_owned(),
2570 session_id.to_owned(),
2571 name.to_owned(),
2572 template.image.clone(),
2573 docker_pull_policy(template).to_owned(),
2574 ];
2575 for (ordinal, mount) in writable {
2576 args.extend([
2577 ordinal.to_string(),
2578 mount.source.to_string_lossy().into_owned(),
2579 docker_overlay_volume_name(name, ordinal),
2580 ]);
2581 }
2582 args.extend(["--".to_owned(), "docker".to_owned()]);
2583 args.extend(run_args);
2584 Ok(CommandSpec::new("sh", args)
2585 .purpose("start Docker session container with isolated attachments")
2586 .stage(ProvisionStage::Provisioning)
2587 .creates_target())
2588}
2589
2590fn container_run_args(
2591 engine: &str,
2592 template: &ContainerTemplate,
2593 name: &str,
2594 session_id: &str,
2595 additional_mounts: &[AdditionalMount],
2596 podman_workspace: Option<&PodmanWorkspaceLocator>,
2597) -> Result<Vec<String>> {
2598 validate_additional_mounts(additional_mounts)?;
2599 let mut args = vec!["run".to_owned()];
2600 if engine == "podman" {
2601 let pull_policy = template.pull_policy.at_launch(&template.image);
2602 if pull_policy != ImagePullPolicy::Missing {
2603 args.push(format!("--pull={}", pull_policy.podman_value()));
2604 }
2605 args.push("--init".to_owned());
2609 } else if engine == "docker" {
2610 let pull = docker_pull_policy(template);
2611 args.push(format!("--pull={pull}"));
2612 args.push("--init".to_owned());
2613 }
2614 args.extend(["--detach".to_owned(), "--name".to_owned(), name.to_owned()]);
2615 args.extend(managed_resource_identity_args(
2616 ManagedResourceKind::Container,
2617 session_id,
2618 ));
2619 args.extend(template.extra_run_args.clone());
2620 if engine == "podman" {
2621 match podman_workspace.unwrap_or(&PodmanWorkspaceLocator::ContainerLayer) {
2622 PodmanWorkspaceLocator::ContainerLayer => {}
2623 PodmanWorkspaceLocator::Volume { name } => args.extend([
2624 "--volume".to_owned(),
2625 format!("{name}:{CONTAINER_WORKSPACE}:rw,U"),
2626 ]),
2627 PodmanWorkspaceLocator::HostPath { path, .. } => args.extend([
2628 "--volume".to_owned(),
2629 format!("{path}:{CONTAINER_WORKSPACE}:rw"),
2630 ]),
2631 }
2632 }
2633 for (ordinal, mount) in additional_mounts.iter().enumerate() {
2634 let source = mount.source.to_string_lossy();
2635 let destination = mount.destination.to_string_lossy();
2636 match engine {
2637 "podman" => {
2638 let mode = if mount.read_only { "ro" } else { "O" };
2639 args.extend([
2640 "--volume".to_owned(),
2641 format!("{source}:{destination}:{mode}"),
2642 ]);
2643 }
2644 "docker" => {
2645 let source = if mount.read_only {
2646 source.into_owned()
2647 } else {
2648 docker_overlay_volume_name(name, ordinal)
2649 };
2650 let suffix = if mount.read_only { ":ro" } else { "" };
2651 args.extend([
2652 "--volume".to_owned(),
2653 format!("{source}:{destination}{suffix}"),
2654 ]);
2655 }
2656 "container" => args.extend([
2657 "--mount".to_owned(),
2658 format!("type=bind,source={source},target={destination},readonly"),
2659 ]),
2660 _ => bail!("additional mounts are unsupported for container engine {engine:?}"),
2661 }
2662 }
2663 args.extend([
2664 template.image.clone(),
2665 "sleep".to_owned(),
2666 "infinity".to_owned(),
2667 ]);
2668 Ok(args)
2669}
2670
2671fn apple_image_prepare_commands(template: &ContainerTemplate) -> Vec<CommandSpec> {
2672 let command = match template.pull_policy.resolve(&template.image) {
2673 ImagePullPolicy::Always | ImagePullPolicy::Newer => {
2674 CommandSpec::new("container", ["image", "pull", template.image.as_str()])
2675 .purpose(format!("refresh container image {}", template.image))
2676 }
2677 ImagePullPolicy::Never => {
2678 CommandSpec::new("container", ["image", "inspect", template.image.as_str()])
2679 .purpose(format!("find pinned container image {}", template.image))
2680 }
2681 ImagePullPolicy::Missing => return Vec::new(),
2682 ImagePullPolicy::Auto => unreachable!("auto pull policy must resolve"),
2683 };
2684 vec![command.stage(ProvisionStage::Provisioning)]
2685}
2686
2687fn command_over_ssh(mut command: CommandSpec, ssh: &SshTarget) -> CommandSpec {
2689 let remote = std::iter::once(command.program)
2690 .chain(command.args)
2691 .collect();
2692 let wrapped = ssh_command_owned(ssh, remote);
2693 command.program = wrapped.program;
2694 command.args = wrapped.args;
2695 command
2696}
2697
2698fn at_boundary(boundary: ExecutionBoundary<'_>, args: Vec<String>) -> CommandSpec {
2699 match boundary {
2700 ExecutionBoundary::Direct => CommandSpec::new(args[0].clone(), args[1..].iter().cloned()),
2701 ExecutionBoundary::Container {
2702 engine,
2703 container_id,
2704 } => container_exec(engine, container_id, args),
2705 ExecutionBoundary::Ssh(ssh) => ssh_command_owned(ssh, args),
2706 ExecutionBoundary::SshContainer {
2707 engine,
2708 ssh,
2709 container_id,
2710 } => {
2711 let mut remote = vec![
2712 engine.to_owned(),
2713 "exec".to_owned(),
2714 "-i".to_owned(),
2715 container_id.to_owned(),
2716 ];
2717 remote.extend(args);
2718 ssh_command_owned(ssh, remote)
2719 }
2720 }
2721}
2722
2723#[cfg(test)]
2724mod tests;
2725
2726pub fn probe_filesystem_types(
2733 ssh: Option<&SshTarget>,
2734 paths: &[PathBuf],
2735 executor: &impl CommandExecutor,
2736) -> Result<Vec<String>> {
2737 if paths.is_empty() {
2738 return Ok(Vec::new());
2739 }
2740 let mut args = vec![
2741 "stat".to_owned(),
2742 "-f".to_owned(),
2743 "-c".to_owned(),
2744 "%T".to_owned(),
2745 "--".to_owned(),
2746 ];
2747 args.extend(paths.iter().map(|path| path.to_string_lossy().into_owned()));
2748 let host = match ssh {
2749 Some(ssh) => PodmanHost::Ssh(ssh),
2750 None => PodmanHost::Local,
2751 };
2752 let output = executor.execute(&host.command_owned(args, "probe mount source filesystem"))?;
2753 if output.status != 0 {
2754 bail!(
2755 "filesystem probe failed with status {}: {}",
2756 output.status,
2757 String::from_utf8_lossy(&output.stderr).trim()
2758 );
2759 }
2760 let types = String::from_utf8_lossy(&output.stdout)
2761 .lines()
2762 .map(|line| line.trim().to_owned())
2763 .collect::<Vec<_>>();
2764 if types.len() != paths.len() {
2765 bail!(
2766 "filesystem probe named {} filesystems for {} directories",
2767 types.len(),
2768 paths.len()
2769 );
2770 }
2771 Ok(types)
2772}