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