Skip to main content

mj_controller/
targets.rs

1//! Declarative execution plans for Hel session targets.
2//!
3//! Plans deliberately contain argv vectors instead of local shell strings.  A
4//! shell is used only at the SSH boundary, where OpenSSH necessarily sends a
5//! command string; every remotely supplied argument is POSIX-quoted there.
6
7use 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
19// `mj doctor` prints a self-contained setup page that quotes these two pages in
20// full. They are embedded here, beside the paths that name them, because this
21// crate's `include` list is what carries `docs/` into the published package;
22// the controller crate that renders the page cannot reach outside its own
23// directory.
24/// The rootless Podman postconditions page, verbatim.
25pub const PODMAN_DOCUMENTATION: &str = include_str!("../docs/PODMAN.md");
26/// The Docker postconditions page, verbatim.
27pub 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
37/// Build command-line fragments that identify resources Hel owns for a session.
38fn 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    /// Non-fatal host configuration problems that can make sessions fragile.
59    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/// Where the Podman prerequisite probes run.
75///
76/// The same postconditions apply locally and over SSH; only the command
77/// wrapping and the wording of a failure differ.
78#[derive(Debug, Clone, Copy, PartialEq, Eq)]
79enum PodmanHost<'a> {
80    Local,
81    Ssh(&'a SshTarget),
82}
83
84impl PodmanHost<'_> {
85    /// Sentence opener for every failure raised by these probes.
86    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    /// Prefix that says where a remediation must be applied.
94    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
116/// Verify the fast local preconditions for Hel's rootless Podman target.
117///
118/// This intentionally never pulls an image. Image availability is verified by
119/// `mj setup`'s smoke test and by the subsequent target creation command.
120pub 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
129/// Verify that the Docker CLI can reach a Linux Docker daemon.
130///
131/// Image and OverlayFS support are exercised by the setup/doctor smoke test;
132/// this fast probe runs before every launch and never pulls an image.
133pub 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
187/// Verify the same rootless Podman preconditions on an SSH host.
188///
189/// The probes run through the noninteractive SSH options, so an unreachable
190/// host fails fast instead of blocking doctor or session preflight.
191pub 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
260/// Report either an explicitly unsafe systemd setting or an unavailable
261/// durability check. Neither condition makes an otherwise usable target fail.
262fn 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    // `ssh` reserves this status for its own connection failures; the Podman
345    // probes never produce it. Reporting that case separately keeps an
346    // unreachable host from being mistaken for a broken Podman installation.
347    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
423/// Create the initial resource. AWS address discovery and all SSH bootstrap
424/// happen after parsing the `run-instances` response and constructing a locator.
425pub 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
616/// Build the no-op infrastructure plan for an existing bare project.
617/// The wizard validates the project for early feedback; worker/ACP startup is
618/// authoritative if it changes before launch. Worker state is installed later
619/// under the dedicated worker and profile roots, not under a cloned workspace.
620pub 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
641/// Create the short-lived local container used to verify a setup target.
642///
643/// This deliberately shares the same argv construction as session targets so
644/// setup catches an unusable image or runtime before the first session exists.
645pub 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
698/// Run the disposable setup smoke test and always attempt container cleanup
699/// after a successful create step.
700pub 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        // Never remove a lower directory until its container and volumes are gone.
780        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
797// macOS temporary directories are not normally shared into Docker VMs. The
798// home directory is shared by Colima's default configuration.
799fn 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        // The disposable probe tests the mount, independently of host/image UID.
825        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        // A surviving overlay still references its lower directory.
848        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
884/// Clone/bootstrap commands for AWS once the exact instance ID and address are known.
885pub 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
962/// Describe safe recovery for a container that belongs to an active
963/// session. The inspect command is deliberately separate from `exec`: a host
964/// crash can leave the container present but stopped, where `exec` cannot
965/// distinguish that state from other transport failures.
966pub 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
1033/// Start a confirmed stopped container target and verify it reached `running`.
1034/// Missing or foreign containers, transport failures, and transitional states
1035/// fail without running the start command.
1036pub 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        // `podman container exists` deliberately reserves 1 for absence and
1049        // uses 125 for invocation or storage failures. SSH preserves the
1050        // remote exit status, so this contract also covers remote Podman.
1051        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
1188// `du` is run on its own so a path it cannot measure fails the probe instead of
1189// being silently dropped from the total: a session that reports less disk than
1190// it uses is worse than one that reports none. Its stderr is deliberately left
1191// attached, so the caller's failure message names the path that could not be
1192// read.
1193const 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
1366/// Read the single byte count every writable-disk probe answers with.
1367///
1368/// A probe that ran and answered something else measured nothing, which must be
1369/// reported as a failure rather than silently becoming "disk usage unknown":
1370/// only a probe that was never run leaves the value unknown.
1371fn 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
1468/// POSIX shell helpers that identify the daemon for one exact worker root.
1469/// The match is assembled at run time so the script's own command line cannot
1470/// select itself, and `worker proxy` command lines cannot match either.
1471fn 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
1500/// Report whether the exact session worker is alive without signaling it.
1501/// A successful probe prints one stable token; transport or shell failures
1502/// stay distinguishable from a confirmed absent worker.
1503pub 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
1535/// Stop the detached worker daemon rooted at `worker_root`.
1536///
1537/// The daemon leads its own process group, so the signal goes to the group
1538/// first to take the agent down with it. Shells disagree about how to write a
1539/// negative PID (`dash` rejects `--`), hence the two forms before the
1540/// single-process fallback for daemons predating the group leadership.
1541pub 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
1600/// Stop a leaked worker and delete the durable relay state under its root.
1601///
1602/// A resume seeds fresh relay state into the same root a closed session used.
1603/// Leftover state wins over that seed at startup, so it has to go, and
1604/// whatever might still be writing it has to go first. Container and instance
1605/// targets are rebuilt from scratch on resume, so they need nothing here.
1606pub 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            // The daemon dies before its root does: a survivor's next durable
1672            // write would recreate the directory this command removes.
1673            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            // EC2 TerminateInstances is explicitly idempotent, including a
1751            // repeated request for an already-terminated instance.
1752            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            // Same ordering constraint as the local bare target: stop the
1769            // daemon before deleting the root it keeps writing to.
1770            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
1789/// Stop and remove only a child session's private worker state from a target
1790/// owned by its parent. This never removes the target or project workspace.
1791pub 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
1834/// Stop a Podman target without deleting its potentially large writable layer.
1835/// A successful return means the exact owned container is absent or not running.
1836pub 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
1992/// Confirm that a container is absent after its exact delete command failed.
1993/// Other target deletion commands are already idempotent: filesystem removal
1994/// uses `rm -rf`, Podman uses `--ignore`, and EC2 termination is an idempotent
1995/// API operation. Apple lists exact container IDs; Docker checks both the exact
1996/// container name and exact session-labeled volumes while distinguishing an
1997/// unavailable daemon from absence.
1998pub 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
2152/// Compatibility is intentionally interpreted by the controller. A successful
2153/// probe permits an image-baked tool to be reused; a missing/incompatible tool
2154/// causes the controller to upload/install its release-owned copy.
2155pub 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
2186/// Thin Linux Git bootstrap. Managed containers also receive GitHub CLI and
2187/// its HTTPS credential helper so an injected `GH_TOKEN` works before clone.
2188pub 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
2211/// Shared [`CommandSpec::parallel_group`] marker for one bundle's per-repository
2212/// clone/init commands. Every `clone_commands` call builds its own
2213/// [`CommandPlan`], so a single fixed marker never mixes batches across plans.
2214const 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        // PID 1 is `sleep infinity`, which reaps nothing, so every exec that
2606        // outlives its parent leaves a zombie behind. Apple's `container`
2607        // engine is left alone: its support for the flag is unverified.
2608        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
2687/// Move a command to the remote host without losing its input or lifecycle metadata.
2688fn 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
2726/// Filesystem type of each directory, probed on the host that runs the
2727/// container engine. `ssh` names that host for a remote Podman target; `None`
2728/// probes this machine.
2729///
2730/// The reply is positional, so the whole batch fails unless `stat` answered for
2731/// every directory in order.
2732pub 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}