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