Skip to main content

mj_core/
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, BTreeSet};
8use std::io::{Read, Write};
9use std::path::{Path, PathBuf};
10use std::process::{Command, Stdio};
11use std::sync::Arc;
12use std::sync::atomic::{AtomicBool, Ordering};
13use std::time::{Duration, Instant};
14
15use anyhow::{Context, Result, bail, ensure};
16use serde::{Deserialize, Serialize};
17use sha2::{Digest, Sha256};
18
19use crate::config::{HarnessHost, HarnessKind, ImagePullPolicy};
20
21pub const SESSION_LABEL: &str = "dev.mj.session";
22pub const MANAGED_LABEL: &str = "dev.mj.managed";
23pub const SESSION_TAG: &str = "dev.mj.session";
24pub const MANAGED_TAG: &str = "dev.mj.managed";
25/// Which Mjolnir instance (named `--instance` or data-directory fingerprint)
26/// created a worker; see `config::instance_identity`.
27pub const INSTANCE_LABEL: &str = "dev.mj.instance";
28pub const INSTANCE_TAG: &str = "dev.mj.instance";
29/// The shared in-container workspace every container session used before
30/// per-session workspaces existed. New sessions record a path under it; see
31/// `container_workspace_root`.
32pub const CONTAINER_WORKSPACE: &str = "/workspace";
33
34/// The launch phase a command belongs to, reported as launch progress.
35#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
36pub enum ProvisionStage {
37    /// Waiting for a background download of this target's image to finish,
38    /// rather than starting a second download of the same image.
39    PullingImage,
40    Provisioning,
41    Booting,
42    Cloning,
43    Syncing,
44    Restoring,
45    Starting,
46    Installing(HarnessKind),
47    Compacting,
48    RecoveryCopy,
49    Verifying,
50    Closing,
51    StoppingTarget,
52    RemovingContainer,
53    RemovingStorage,
54    CleaningCache,
55}
56
57impl ProvisionStage {
58    pub fn label(self) -> String {
59        match self {
60            Self::PullingImage => "Pull image".into(),
61            Self::Provisioning => "Provision".into(),
62            Self::Booting => "Boot".into(),
63            Self::Cloning => "Clone".into(),
64            Self::Syncing => "Sync".into(),
65            Self::Restoring => "Restore".into(),
66            Self::Starting => "Start".into(),
67            Self::Installing(harness) => format!("Installing {}", harness.display_name()),
68            Self::Compacting => "Compact".into(),
69            Self::RecoveryCopy => "Recovery copy".into(),
70            Self::Verifying => "Verify".into(),
71            Self::Closing => "Close".into(),
72            Self::StoppingTarget => "Stop target".into(),
73            Self::RemovingContainer => "Remove container".into(),
74            Self::RemovingStorage => "Remove container storage".into(),
75            Self::CleaningCache => "Clean cache".into(),
76        }
77    }
78}
79
80#[derive(Clone, PartialEq, Eq)]
81struct SensitiveCommandInput(Vec<u8>);
82
83impl std::fmt::Debug for SensitiveCommandInput {
84    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
85        formatter.write_str("<redacted>")
86    }
87}
88
89#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
90pub struct CommandSpec {
91    pub program: String,
92    pub args: Vec<String>,
93    #[serde(default)]
94    pub env: BTreeMap<String, String>,
95    /// Replace ambient process variables with the explicitly supplied environment.
96    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
97    pub clear_env: bool,
98    #[serde(default, skip_serializing_if = "Option::is_none")]
99    pub cwd: Option<std::path::PathBuf>,
100    pub purpose: String,
101    #[serde(default)]
102    pub stage: Option<ProvisionStage>,
103    /// Commands that share this marker and appear consecutively in a plan's
104    /// command list may run concurrently under
105    /// [`CommandPlan::execute_concurrent`]. Commands without a marker, or
106    /// whose neighbors do not share it, keep running strictly in plan order.
107    #[serde(default)]
108    pub parallel_group: Option<u32>,
109    /// Whether this command brings the session's target into existence. Every
110    /// command after it in a provisioning plan runs against a target that
111    /// already exists, so a later failure owes that target's teardown.
112    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
113    pub creates_target: bool,
114    /// The SSH destination this command opens a connection to, when it does.
115    /// Tagged commands pass through [`SshAdmission`] so the daemon never
116    /// exceeds the remote `sshd`'s `MaxStartups` budget, and a transport
117    /// rejection is retried rather than reported as a command failure.
118    #[serde(default, skip_serializing_if = "Option::is_none")]
119    pub ssh_destination: Option<String>,
120    /// Input that must reach the child without becoming part of its arguments,
121    /// environment, serialized plan, or debug representation.
122    #[serde(skip)]
123    sensitive_stdin: Option<SensitiveCommandInput>,
124}
125
126impl CommandSpec {
127    pub fn new(
128        program: impl Into<String>,
129        args: impl IntoIterator<Item = impl Into<String>>,
130    ) -> Self {
131        Self {
132            program: program.into(),
133            args: args.into_iter().map(Into::into).collect(),
134            env: BTreeMap::new(),
135            clear_env: false,
136            cwd: None,
137            purpose: String::new(),
138            stage: None,
139            parallel_group: None,
140            creates_target: false,
141            ssh_destination: None,
142            sensitive_stdin: None,
143        }
144    }
145
146    pub fn purpose(mut self, purpose: impl Into<String>) -> Self {
147        self.purpose = purpose.into();
148        self
149    }
150
151    pub fn stage(mut self, stage: ProvisionStage) -> Self {
152        self.stage = Some(stage);
153        self
154    }
155
156    /// Mark this command as eligible to run concurrently with its
157    /// plan-adjacent siblings that share the same group.
158    pub fn parallel_group(mut self, group: u32) -> Self {
159        self.parallel_group = Some(group);
160        self
161    }
162
163    /// Record that this command opens an `ssh` connection to `destination`,
164    /// which is the host (or `user@host`) argument, never an option value.
165    pub fn ssh_destination(mut self, destination: impl Into<String>) -> Self {
166        self.ssh_destination = Some(destination.into());
167        self
168    }
169
170    /// Mark this command as the one that creates the session's target.
171    pub fn creates_target(mut self) -> Self {
172        self.creates_target = true;
173        self
174    }
175
176    /// Feed private file content through the shared concurrent pipe handler.
177    /// The bytes stay out of argv, environments, serialization, and Debug.
178    pub fn with_sensitive_stdin(mut self, input: Vec<u8>) -> Self {
179        self.sensitive_stdin = Some(SensitiveCommandInput(input));
180        self
181    }
182}
183
184#[derive(Debug, Clone, PartialEq, Eq)]
185pub struct CommandOutput {
186    pub status: i32,
187    pub stdout: Vec<u8>,
188    pub stderr: Vec<u8>,
189}
190
191#[derive(Debug, Clone, PartialEq, Eq)]
192pub struct SessionResourceUsage {
193    pub cpu_percent: Option<u8>,
194    pub memory_current_bytes: u64,
195    pub memory_limit_bytes: Option<u64>,
196    pub swap_current_bytes: Option<u64>,
197    pub swap_limit_bytes: Option<u64>,
198    pub writable_disk_bytes: Option<u64>,
199}
200
201#[derive(Debug, Clone, PartialEq, Eq)]
202pub struct SessionResourceProbe {
203    pub memory: CommandSpec,
204    pub disk: Option<CommandSpec>,
205}
206
207#[derive(Debug, Clone, Copy, PartialEq, Eq)]
208pub enum DeploymentCapacityKind {
209    Host,
210    AwsFleet,
211}
212
213#[derive(Debug, Clone, PartialEq, Eq)]
214pub struct DeploymentCapacityTarget {
215    pub id: String,
216    pub host: String,
217    pub target_ids: Vec<String>,
218    pub kind: DeploymentCapacityKind,
219    pub local: bool,
220    /// Alternative commands for a host, or one command per live AWS instance.
221    pub probes: Vec<CommandSpec>,
222    /// Prevents a partial AWS fleet sample when one live instance cannot be probed yet.
223    pub probe_error: Option<String>,
224}
225
226#[derive(Debug, Clone, PartialEq, Eq)]
227pub struct DeploymentCapacityUsage {
228    pub cpu_percent: Option<u8>,
229    pub memory_used_bytes: u64,
230    pub memory_total_bytes: u64,
231    pub logical_cores: u64,
232    pub disk_total_bytes: Option<u64>,
233}
234
235/// An additional directory made available to one session.
236///
237/// Containers use isolated mounts. Remote targets may instead receive a
238/// controller-packed snapshot at the destination while retaining this shared
239/// persisted shape.
240#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
241#[serde(from = "AdditionalMountRepr", into = "AdditionalMountRepr")]
242pub struct AdditionalMount {
243    pub source: PathBuf,
244    pub destination: PathBuf,
245    pub access: MountAccess,
246}
247
248/// How a container sees an attached directory.
249#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
250#[serde(rename_all = "snake_case")]
251pub enum MountAccess {
252    /// The container can read the directory but not change it.
253    Ro,
254    /// The container writes to a private copy-on-write overlay; the host
255    /// directory never changes.
256    Cow,
257    /// The container writes straight through to the host directory.
258    Rw,
259}
260
261impl MountAccess {
262    pub const ALL: [Self; 3] = [Self::Ro, Self::Cow, Self::Rw];
263
264    pub fn label(self) -> &'static str {
265        match self {
266            Self::Ro => "ro",
267            Self::Cow => "cow",
268            Self::Rw => "rw",
269        }
270    }
271
272    /// The mode this attachment takes where the host filesystem cannot carry
273    /// a copy-on-write overlay. Read-only is the substitute, because the
274    /// alternative would let the container write through to the host
275    /// directory the user asked to keep unchanged.
276    ///
277    /// This is the one rule for an unavailable overlay: the wizard offers the
278    /// same modes [`Self::offered`] lists, and the runtime downgrades a stored
279    /// mount the same way.
280    pub fn without_overlay(self) -> Self {
281        match self {
282            Self::Cow => Self::Ro,
283            kept => kept,
284        }
285    }
286
287    /// The modes an attachment may be given, given whether the host filesystem
288    /// can carry the copy-on-write overlay.
289    pub fn offered(overlay_available: bool) -> Vec<Self> {
290        Self::ALL
291            .into_iter()
292            .filter(|access| overlay_available || access.without_overlay() == *access)
293            .collect()
294    }
295}
296
297/// The numeric identity of a container image's configured user, read from the
298/// image on the host that runs it.
299#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
300pub struct ImageUser {
301    pub uid: u32,
302    pub gid: u32,
303}
304
305/// Podman's user-namespace option for a session container, or `None` when the
306/// container keeps Podman's default mapping.
307///
308/// A rootless Podman container maps the image's user onto the host user
309/// running the daemon, so a file the container writes into an attached
310/// directory is owned by that host user instead of a subordinate id. The
311/// mapping has to name the image's own ids: plain `keep-id` maps the host user
312/// onto uid 1000 inside the container and demotes an image that runs as root,
313/// which is a change in how the container runs. Without the ids there is no
314/// safe option to pass, so the container runs the way it did before.
315pub fn podman_userns_option(image_user: Option<ImageUser>) -> Option<String> {
316    image_user.map(|ImageUser { uid, gid }| format!("--userns=keep-id:uid={uid},gid={gid}"))
317}
318
319/// The persisted shape. Read-only and copy-on-write mounts keep the original
320/// `read_only` boolean alone, so archives and API payloads that older builds
321/// read stay unchanged; only read-write adds `access`, which older builds
322/// cannot represent.
323#[derive(Serialize, Deserialize)]
324#[serde(deny_unknown_fields)]
325struct AdditionalMountRepr {
326    source: PathBuf,
327    destination: PathBuf,
328    #[serde(default)]
329    read_only: bool,
330    #[serde(default, skip_serializing_if = "Option::is_none")]
331    access: Option<MountAccess>,
332}
333
334impl From<AdditionalMountRepr> for AdditionalMount {
335    fn from(repr: AdditionalMountRepr) -> Self {
336        let access = repr.access.unwrap_or(if repr.read_only {
337            MountAccess::Ro
338        } else {
339            MountAccess::Cow
340        });
341        Self {
342            source: repr.source,
343            destination: repr.destination,
344            access,
345        }
346    }
347}
348
349impl From<AdditionalMount> for AdditionalMountRepr {
350    fn from(mount: AdditionalMount) -> Self {
351        Self {
352            source: mount.source,
353            destination: mount.destination,
354            read_only: mount.access == MountAccess::Ro,
355            access: (mount.access == MountAccess::Rw).then_some(MountAccess::Rw),
356        }
357    }
358}
359
360/// Why a filesystem cannot host a container target's copy-on-write overlay,
361/// or `None` when it can. Unknown types are allowed: the overlay is the better
362/// mount and only a filesystem known to break it is downgraded.
363///
364/// The names are those `stat -f -c %T` reports, matched case-insensitively.
365pub fn overlay_unsupported_filesystem(filesystem: &str) -> Option<&'static str> {
366    let name = filesystem.trim().to_ascii_lowercase();
367    // FUSE reports the backing driver as `fuse.sshfs`, `fuse.s3fs`, and so on.
368    if name == "fuse" || name == "fuseblk" || name.starts_with("fuse.") {
369        return Some("FUSE filesystem");
370    }
371    match name.as_str() {
372        "nfs" | "nfs4" | "cifs" | "smb2" | "smb3" | "9p" | "v9fs" | "virtiofs" | "ceph"
373        | "lustre" | "afs" | "glusterfs" | "ocfs2" | "gfs" | "gfs2" => Some("network filesystem"),
374        "msdos" | "vfat" | "fat" | "exfat" | "ntfs" | "ntfs3" => Some("no POSIX metadata"),
375        "overlayfs" => Some("overlay stacking limit"),
376        _ => None,
377    }
378}
379
380/// Container destinations cannot use the controller or login user's home.
381pub fn validate_mount_destination(path: &Path) -> Result<()> {
382    ensure!(
383        path.is_absolute()
384            && !path
385                .components()
386                .any(|part| part == std::path::Component::ParentDir),
387        "additional mount destination must be a safe absolute container path; ~ is not supported"
388    );
389    Ok(())
390}
391
392pub fn validate_additional_mounts(mounts: &[AdditionalMount]) -> Result<()> {
393    let mut destinations = BTreeSet::new();
394    for mount in mounts {
395        if !mount.source.is_absolute() || mount.source.as_os_str().is_empty() {
396            bail!("additional mount source must be an absolute directory path");
397        }
398        validate_mount_destination(&mount.destination)?;
399        if !destinations.insert(mount.destination.clone()) {
400            bail!(
401                "additional mount destination {:?} is configured more than once",
402                mount.destination
403            );
404        }
405    }
406    Ok(())
407}
408
409/// Choose the editable default destination for an additional host directory.
410pub fn default_mount_destination(source: &Path, existing: &[AdditionalMount]) -> PathBuf {
411    let basename = source
412        .file_name()
413        .filter(|name| !name.is_empty())
414        .unwrap_or_else(|| std::ffi::OsStr::new("mount"));
415    let base = PathBuf::from("/mnt").join(basename);
416    if !existing.iter().any(|mount| mount.destination == base) {
417        return base;
418    }
419    for number in 2.. {
420        let candidate =
421            PathBuf::from("/mnt").join(format!("{}-{number}", basename.to_string_lossy()));
422        if !existing.iter().any(|mount| mount.destination == candidate) {
423            return candidate;
424        }
425    }
426    unreachable!("a finite mount list always has an unused numbered destination")
427}
428
429pub trait CommandExecutor {
430    fn execute(&self, command: &CommandSpec) -> Result<CommandOutput>;
431
432    /// Whether the operation supervising this executor has requested
433    /// cancellation. Test executors and ordinary process execution are not
434    /// cancellable unless they opt in.
435    fn cancellation_requested(&self) -> bool {
436        false
437    }
438
439    /// Report entry into a lifecycle stage. Callers that cover more than one
440    /// command should hold a [`ProvisionStageGuard`] for the whole operation
441    /// so concurrent stages remain visible between subprocesses.
442    fn stage_started(&self, _stage: ProvisionStage) {}
443
444    /// Report exit from a lifecycle stage previously passed to
445    /// [`Self::stage_started`].
446    fn stage_finished(&self, _stage: ProvisionStage) {}
447
448    /// Report a decision an operation made on the user's behalf. This is not a
449    /// failure: the work continues, and the user is told what changed.
450    fn notify_notice(&self, _notice: &str) {}
451
452    fn execute_with_stdin(
453        &self,
454        _command: &CommandSpec,
455        _input: &mut (dyn Read + Send),
456    ) -> Result<CommandOutput> {
457        bail!("this command executor does not support streamed stdin")
458    }
459}
460
461/// A scoped lifecycle-stage report for controller-side work or a sequence of
462/// commands. Dropping the guard reports completion even when the work returns
463/// early with an error.
464pub struct ProvisionStageGuard<'a, E: CommandExecutor + ?Sized> {
465    executor: &'a E,
466    stage: ProvisionStage,
467}
468
469impl<'a, E: CommandExecutor + ?Sized> ProvisionStageGuard<'a, E> {
470    pub fn new(executor: &'a E, stage: ProvisionStage) -> Self {
471        executor.stage_started(stage);
472        Self { executor, stage }
473    }
474}
475
476impl<E: CommandExecutor + ?Sized> Drop for ProvisionStageGuard<'_, E> {
477    fn drop(&mut self) {
478        self.executor.stage_finished(self.stage);
479    }
480}
481
482pub struct ProcessExecutor;
483
484/// Run one `ssh` invocation under process-wide admission control, retrying it
485/// when the server turned the connection away before authentication.
486///
487/// A permit is held only while a child is actually running and is released
488/// between attempts, because a waiting retry occupies no connection slot. A
489/// transport rejection means the remote command never started, so re-running
490/// the whole invocation cannot repeat a side effect.
491///
492/// Commands that are not tagged with a destination run untouched.
493fn with_ssh_admission(
494    command: &CommandSpec,
495    is_cancelled: &dyn Fn() -> bool,
496    mut run: impl FnMut() -> Result<CommandOutput>,
497) -> Result<CommandOutput> {
498    let Some(destination) = command.ssh_destination.as_deref() else {
499        return run();
500    };
501    for attempt in 1..=SSH_RETRY_ATTEMPTS {
502        let output = {
503            let _permit = SshAdmission::acquire(destination);
504            run()?
505        };
506        if attempt == SSH_RETRY_ATTEMPTS
507            || !is_transport_rejection(output.status, &String::from_utf8_lossy(&output.stderr))
508        {
509            return Ok(output);
510        }
511        let delay = ssh_retry_delay(attempt);
512        tracing::warn!(
513            destination,
514            purpose = command.purpose.as_str(),
515            attempt,
516            attempts = SSH_RETRY_ATTEMPTS,
517            delay_ms = delay.as_millis() as u64,
518            stderr = String::from_utf8_lossy(&output.stderr).trim(),
519            "ssh was refused by the server before authentication; retrying"
520        );
521        if !sleep_unless_cancelled(delay, is_cancelled) {
522            bail!("operation cancelled while {}", command.purpose);
523        }
524    }
525    unreachable!("the final attempt always returns");
526}
527
528/// Wait out `delay`, giving up early if the supervising operation is
529/// cancelled. Returns whether the wait completed.
530fn sleep_unless_cancelled(delay: Duration, is_cancelled: &dyn Fn() -> bool) -> bool {
531    let deadline = Instant::now() + delay;
532    loop {
533        if is_cancelled() {
534            return false;
535        }
536        let remaining = deadline.saturating_duration_since(Instant::now());
537        if remaining.is_zero() {
538            return true;
539        }
540        std::thread::sleep(remaining.min(Duration::from_millis(50)));
541    }
542}
543
544/// One debug line per finished target command, so a slow launch or resume
545/// phase can be attributed from logs instead of re-profiled by hand.
546pub fn trace_command_duration(command: &CommandSpec, started: Instant, status: i32) {
547    tracing::debug!(
548        purpose = command.purpose.as_str(),
549        program = command.program.as_str(),
550        status,
551        elapsed_ms = started.elapsed().as_millis() as u64,
552        "target command finished"
553    );
554}
555
556impl ProcessExecutor {
557    /// One attempt, with no admission or retry of its own.
558    fn run_once(&self, command: &CommandSpec) -> Result<CommandOutput> {
559        if let Some(input) = &command.sensitive_stdin {
560            let mut input = std::io::Cursor::new(input.0.as_slice());
561            // Owned bytes, so each attempt gets its own reader.
562            return stream_command_with_stdin(
563                configured_command(command),
564                command,
565                &mut input,
566                &|| false,
567            );
568        }
569        let started = Instant::now();
570        let output = configured_command(command)
571            .stdin(Stdio::null())
572            .output()
573            .with_context(|| format!("run {} for {}", command.program, command.purpose))?;
574        let status = output.status.code().unwrap_or(-1);
575        trace_command_duration(command, started, status);
576        Ok(CommandOutput {
577            status,
578            stdout: output.stdout,
579            stderr: output.stderr,
580        })
581    }
582}
583
584impl CommandExecutor for ProcessExecutor {
585    fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
586        with_ssh_admission(command, &|| false, || self.run_once(command))
587    }
588
589    fn execute_with_stdin(
590        &self,
591        command: &CommandSpec,
592        input: &mut (dyn Read + Send),
593    ) -> Result<CommandOutput> {
594        // A caller's stream cannot be replayed, so this path takes a permit
595        // but never retries.
596        let _permit = command
597            .ssh_destination
598            .as_deref()
599            .map(SshAdmission::acquire);
600        let process = configured_command(command);
601        // Plain process execution is not cancellable, so the transfer only
602        // ends when the child does.
603        stream_command_with_stdin(process, command, input, &|| false)
604    }
605}
606
607/// Streams `input` into a freshly spawned child and collects its output.
608///
609/// Both executors share this one implementation because the pipe edge cases
610/// below are easy to get subtly wrong in a second copy.
611///
612/// `is_cancelled` reports whether the supervising operation wants the transfer
613/// abandoned; [`ProcessExecutor`] passes a check that is never true, which also
614/// makes the kill path below unreachable for it.
615fn stream_command_with_stdin(
616    mut process: Command,
617    command: &CommandSpec,
618    input: &mut (dyn Read + Send),
619    is_cancelled: &(dyn Fn() -> bool + Sync),
620) -> Result<CommandOutput> {
621    let started = Instant::now();
622    if is_cancelled() {
623        bail!("operation cancelled");
624    }
625    let mut child = process
626        .stdin(Stdio::piped())
627        .stdout(Stdio::piped())
628        .stderr(Stdio::piped())
629        .spawn()
630        .with_context(|| format!("run {} for {}", command.program, command.purpose))?;
631    let stdin = child
632        .stdin
633        .take()
634        .context("streamed command stdin missing")?;
635    let mut stdout = child
636        .stdout
637        .take()
638        .context("streamed command stdout missing")?;
639    let mut stderr = child
640        .stderr
641        .take()
642        .context("streamed command stderr missing")?;
643    // Reader threads keep the child's output pipes drained; a child that fills
644    // one while nobody reads would block instead of exiting.
645    let stdout_reader = std::thread::spawn(move || {
646        let mut bytes = Vec::new();
647        std::io::copy(&mut stdout, &mut bytes).map(|_| bytes)
648    });
649    let stderr_reader = std::thread::spawn(move || {
650        let mut bytes = Vec::new();
651        std::io::copy(&mut stderr, &mut bytes).map(|_| bytes)
652    });
653    let process_result = std::thread::scope(|scope| -> Result<_> {
654        // Pipe writes can block forever when a remote helper stops reading.
655        // Keep the writer off the supervising thread so cancellation can kill
656        // the process group and thereby close the blocked pipe.
657        let input_writer = scope.spawn(move || -> Result<()> {
658            // Owning `stdin` here is what closes the pipe's write end once the
659            // transfer finishes. A child that reads to EOF, such as
660            // `mj worker export-checkpoint --spec -`, never exits while any
661            // copy of the write end is still open.
662            let mut stdin = stdin;
663            let mut buffer = [0_u8; 64 * 1024];
664            loop {
665                // Checking before each chunk makes large checkpoint copies
666                // cooperatively cancellable without changing the executor
667                // interface.
668                if is_cancelled() {
669                    bail!("operation cancelled");
670                }
671                let count = input.read(&mut buffer).context("read command input")?;
672                if count == 0 {
673                    break;
674                }
675                stdin
676                    .write_all(&buffer[..count])
677                    .context("stream command input")?;
678            }
679            stdin.flush().context("flush command input")
680        });
681        let status = loop {
682            if is_cancelled() {
683                terminate_cancellable_child(&mut child);
684                if let Err(error) = input_writer.join() {
685                    tracing::warn!(
686                        purpose = command.purpose.as_str(),
687                        "streamed command input writer panicked while cancelling: {error:?}"
688                    );
689                }
690                bail!("operation cancelled while {}", command.purpose);
691            }
692            match child.try_wait() {
693                Ok(Some(status)) => break status,
694                Ok(None) => std::thread::sleep(Duration::from_millis(25)),
695                Err(error) => {
696                    terminate_cancellable_child(&mut child);
697                    if let Err(join_error) = input_writer.join() {
698                        tracing::warn!(
699                            purpose = command.purpose.as_str(),
700                            "streamed command input writer panicked while waiting: {join_error:?}"
701                        );
702                    }
703                    return Err(error).with_context(|| format!("wait for {}", command.purpose));
704                }
705            }
706        };
707        let input_result = input_writer
708            .join()
709            .map_err(|_| anyhow::anyhow!("streamed command input writer panicked"))?;
710        Ok((status, input_result))
711    });
712    let stdout = stdout_reader
713        .join()
714        .map_err(|_| anyhow::anyhow!("streamed command stdout reader panicked"))??;
715    let stderr = stderr_reader
716        .join()
717        .map_err(|_| anyhow::anyhow!("streamed command stderr reader panicked"))??;
718    let (status, input_result) = process_result?;
719    if status.success() {
720        // A child that exited first explains the failure through its own
721        // status and stderr; the broken pipe that exit caused would only hide
722        // it. A successful child must not hide an input error.
723        input_result?;
724    }
725    let status = status.code().unwrap_or(-1);
726    trace_command_duration(command, started, status);
727    Ok(CommandOutput {
728        status,
729        stdout,
730        stderr,
731    })
732}
733
734#[derive(Clone)]
735pub struct CancellableProcessExecutor {
736    cancelled: Arc<AtomicBool>,
737    deadline: Option<Instant>,
738}
739
740impl CancellableProcessExecutor {
741    pub fn new(cancelled: Arc<AtomicBool>) -> Self {
742        Self {
743            cancelled,
744            deadline: None,
745        }
746    }
747
748    pub fn is_cancelled(&self) -> bool {
749        self.cancelled.load(Ordering::Acquire)
750            || self
751                .deadline
752                .is_some_and(|deadline| Instant::now() >= deadline)
753    }
754
755    pub fn with_timeout(timeout: Duration) -> Self {
756        Self {
757            cancelled: Arc::new(AtomicBool::new(false)),
758            deadline: Some(Instant::now() + timeout),
759        }
760    }
761
762    /// Bounds an existing flag-based executor with a deadline, so a wedged
763    /// child becomes a reported failure instead of running forever.
764    pub fn with_deadline(mut self, timeout: Duration) -> Self {
765        self.deadline = Some(Instant::now() + timeout);
766        self
767    }
768
769    fn check_cancelled(&self) -> Result<()> {
770        if self.is_cancelled() {
771            bail!("operation cancelled");
772        }
773        Ok(())
774    }
775}
776
777fn configured_command(command: &CommandSpec) -> Command {
778    let mut process = Command::new(&command.program);
779    if command.clear_env {
780        process.env_clear();
781    }
782    if let Some(cwd) = &command.cwd {
783        process.current_dir(cwd);
784    }
785    process.args(&command.args).envs(&command.env);
786    process
787}
788
789fn cancellable_command(command: &CommandSpec) -> Command {
790    #[cfg(unix)]
791    let mut process = configured_command(command);
792    #[cfg(not(unix))]
793    let process = configured_command(command);
794    #[cfg(unix)]
795    {
796        use std::os::unix::process::CommandExt as _;
797        process.process_group(0);
798    }
799    process
800}
801
802fn terminate_cancellable_child(child: &mut std::process::Child) {
803    #[cfg(unix)]
804    // The child owns a fresh process group, so descendants such as an SSH or
805    // shell helper cannot keep its output pipes open after cancellation. A
806    // group that is already gone is the wanted outcome, not a failure, so the
807    // shared helper decides what deserves a warning.
808    if let Err(error) = crate::subprocess::signal_process_group(child.id() as i32, libc::SIGKILL) {
809        tracing::warn!(pid = child.id(), %error, "could not terminate cancelled command process group");
810    }
811    #[cfg(not(unix))]
812    if let Err(error) = child.kill() {
813        tracing::warn!(pid = child.id(), %error, "could not terminate cancelled command");
814    }
815    if let Err(error) = child.wait() {
816        tracing::warn!(pid = child.id(), %error, "could not reap cancelled command");
817    }
818}
819
820impl CancellableProcessExecutor {
821    /// One attempt, with no admission or retry of its own.
822    fn run_once(&self, command: &CommandSpec) -> Result<CommandOutput> {
823        if let Some(input) = &command.sensitive_stdin {
824            let mut input = std::io::Cursor::new(input.0.as_slice());
825            // Owned bytes, so each attempt gets its own reader.
826            return stream_command_with_stdin(
827                cancellable_command(command),
828                command,
829                &mut input,
830                &|| self.is_cancelled(),
831            );
832        }
833        let started = Instant::now();
834        self.check_cancelled()?;
835        let mut child = cancellable_command(command)
836            .stdin(Stdio::null())
837            .stdout(Stdio::piped())
838            .stderr(Stdio::piped())
839            .spawn()
840            .with_context(|| format!("run {} for {}", command.program, command.purpose))?;
841        let mut stdout = child.stdout.take().context("command stdout missing")?;
842        let mut stderr = child.stderr.take().context("command stderr missing")?;
843        let stdout_reader = std::thread::spawn(move || {
844            let mut bytes = Vec::new();
845            std::io::copy(&mut stdout, &mut bytes).map(|_| bytes)
846        });
847        let stderr_reader = std::thread::spawn(move || {
848            let mut bytes = Vec::new();
849            std::io::copy(&mut stderr, &mut bytes).map(|_| bytes)
850        });
851        let mut status = None;
852        let status = loop {
853            if self.is_cancelled() {
854                terminate_cancellable_child(&mut child);
855                for (stream, reader) in [("stdout", stdout_reader), ("stderr", stderr_reader)] {
856                    match reader.join() {
857                        Ok(Ok(_)) => {}
858                        Ok(Err(error)) => {
859                            tracing::warn!(stream, %error, "cancelled command reader failed")
860                        }
861                        Err(_) => tracing::warn!(stream, "cancelled command reader panicked"),
862                    }
863                }
864                bail!("operation cancelled while {}", command.purpose);
865            }
866            if status.is_none() {
867                status = child
868                    .try_wait()
869                    .with_context(|| format!("wait for {}", command.purpose))?;
870            }
871            // Descendants can retain these pipes after the shell exits. Keep
872            // enforcing the deadline until both readers have actually finished.
873            if let Some(status) = status
874                && stdout_reader.is_finished()
875                && stderr_reader.is_finished()
876            {
877                break status;
878            }
879            std::thread::sleep(Duration::from_millis(25));
880        };
881        let stdout = stdout_reader
882            .join()
883            .map_err(|_| anyhow::anyhow!("command stdout reader panicked"))??;
884        let stderr = stderr_reader
885            .join()
886            .map_err(|_| anyhow::anyhow!("command stderr reader panicked"))??;
887        let status = status.code().unwrap_or(-1);
888        trace_command_duration(command, started, status);
889        Ok(CommandOutput {
890            status,
891            stdout,
892            stderr,
893        })
894    }
895}
896
897impl CommandExecutor for CancellableProcessExecutor {
898    fn cancellation_requested(&self) -> bool {
899        self.is_cancelled()
900    }
901
902    fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
903        with_ssh_admission(command, &|| self.is_cancelled(), || self.run_once(command))
904    }
905
906    fn execute_with_stdin(
907        &self,
908        command: &CommandSpec,
909        input: &mut (dyn Read + Send),
910    ) -> Result<CommandOutput> {
911        // A caller's stream cannot be replayed, so this path takes a permit
912        // but never retries.
913        let _permit = command
914            .ssh_destination
915            .as_deref()
916            .map(SshAdmission::acquire);
917        // The child runs in its own process group so cancellation can kill the
918        // whole group, which is what releases a writer blocked on a full pipe.
919        stream_command_with_stdin(cancellable_command(command), command, input, &|| {
920            self.is_cancelled()
921        })
922    }
923}
924
925/// A command supervised by [`BoundedProcessExecutor`] did not finish before
926/// its deadline. Callers downcast to this to tell a hung probe apart from a
927/// command that could not be started at all.
928#[derive(Debug, Clone, PartialEq, Eq)]
929pub struct CommandTimedOut {
930    pub program: String,
931    pub purpose: String,
932    pub timeout: Duration,
933}
934
935impl std::fmt::Display for CommandTimedOut {
936    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
937        write!(
938            formatter,
939            "`{}` did not answer within {} seconds while trying to {}",
940            self.program,
941            self.timeout.as_secs(),
942            self.purpose
943        )
944    }
945}
946
947impl std::error::Error for CommandTimedOut {}
948
949/// Runs every command with its own deadline.
950///
951/// [`CancellableProcessExecutor::with_timeout`] bounds a whole operation from a
952/// single shared deadline, which suits one provisioning run. Prerequisite
953/// probes are different: each one is expected to answer quickly, and a wedged
954/// socket or blackholed network must not stall the probes that follow it. A
955/// timeout here names the probe that hung, so the caller can report it the same
956/// way it reports any other probe failure.
957#[derive(Debug, Clone, Copy)]
958pub struct BoundedProcessExecutor {
959    timeout: Duration,
960}
961
962impl BoundedProcessExecutor {
963    pub const fn new(timeout: Duration) -> Self {
964        Self { timeout }
965    }
966}
967
968impl CommandExecutor for BoundedProcessExecutor {
969    fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
970        let executor = CancellableProcessExecutor::with_timeout(self.timeout);
971        executor.execute(command).map_err(|error| {
972            if executor.is_cancelled() {
973                anyhow::Error::new(CommandTimedOut {
974                    program: command.program.clone(),
975                    purpose: command.purpose.clone(),
976                    timeout: self.timeout,
977                })
978            } else {
979                error
980            }
981        })
982    }
983
984    fn execute_with_stdin(
985        &self,
986        command: &CommandSpec,
987        input: &mut (dyn Read + Send),
988    ) -> Result<CommandOutput> {
989        CancellableProcessExecutor::with_timeout(self.timeout).execute_with_stdin(command, input)
990    }
991}
992
993#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
994pub struct CommandPlan {
995    pub description: String,
996    pub commands: Vec<CommandSpec>,
997}
998
999impl CommandPlan {
1000    /// Supply one container environment value without placing it in the
1001    /// Podman/SSH argument vector. The target launcher reads the value from
1002    /// stdin, exports it, and asks the container engine to inherit it by name.
1003    pub fn provide_target_environment_secret(
1004        &mut self,
1005        target: &TargetTemplate,
1006        name: &str,
1007        value: &str,
1008    ) -> Result<()> {
1009        ensure!(
1010            !name.is_empty()
1011                && name.bytes().enumerate().all(|(index, byte)| byte == b'_'
1012                    || byte.is_ascii_alphabetic()
1013                    || (index > 0 && byte.is_ascii_digit())),
1014            "invalid secret environment variable name"
1015        );
1016        ensure!(
1017            !value.as_bytes().contains(&b'\n') && !value.as_bytes().contains(&b'\r'),
1018            "secret environment value cannot contain a newline"
1019        );
1020        let command = self
1021            .commands
1022            .iter_mut()
1023            .find(|command| command.creates_target)
1024            .context("provisioning plan has no target creation command")?;
1025        let read_and_export = format!("IFS= read -r {name} || exit 1; export {name};");
1026        match target {
1027            TargetTemplate::LocalPodman(_)
1028            | TargetTemplate::LocalDocker(_)
1029            | TargetTemplate::AppleContainer(_) => {
1030                let program = std::mem::replace(&mut command.program, "sh".to_owned());
1031                let args = std::mem::take(&mut command.args);
1032                command.args = vec![
1033                    "-c".to_owned(),
1034                    format!("{read_and_export} exec \"$@\""),
1035                    "mj-secret-env".to_owned(),
1036                    program,
1037                ];
1038                command.args.extend(args);
1039            }
1040            TargetTemplate::SshPodman { .. } | TargetTemplate::SshDocker { .. } => {
1041                let remote = command
1042                    .args
1043                    .last_mut()
1044                    .context("remote container command has no SSH command argument")?;
1045                *remote = format!("{read_and_export} exec {remote}");
1046            }
1047            TargetTemplate::LocalBare
1048            | TargetTemplate::AwsEc2(_)
1049            | TargetTemplate::SshBare { .. } => {
1050                bail!("target does not support inherited container environment")
1051            }
1052        }
1053        let mut input = value.as_bytes().to_vec();
1054        input.push(b'\n');
1055        command.sensitive_stdin = Some(SensitiveCommandInput(input));
1056        Ok(())
1057    }
1058
1059    pub fn execute(&self, executor: &impl CommandExecutor) -> Result<Vec<CommandOutput>> {
1060        let mut outputs = Vec::with_capacity(self.commands.len());
1061        for command in &self.commands {
1062            let output = executor.execute(command)?;
1063            if output.status != 0 {
1064                bail!(
1065                    "{} failed with status {}: {}",
1066                    command.purpose,
1067                    output.status,
1068                    String::from_utf8_lossy(&output.stderr)
1069                );
1070            }
1071            outputs.push(output);
1072        }
1073        Ok(outputs)
1074    }
1075
1076    /// Execute the plan the same way [`Self::execute`] does, except that
1077    /// commands sharing a [`CommandSpec::parallel_group`] marker and
1078    /// appearing consecutively in `commands` run concurrently as one batch.
1079    ///
1080    /// A batch starts only once every earlier command has succeeded, and a
1081    /// batch that fails reports the first failure in plan order regardless
1082    /// of which command finished first — the same fail-fast contract
1083    /// [`Self::execute`] provides between individual commands. This method
1084    /// requires a `Sync` executor because a batch shares it across threads;
1085    /// [`Self::execute`] keeps working with non-`Sync` executors such as
1086    /// test fakes built on `RefCell`.
1087    pub fn execute_concurrent(
1088        &self,
1089        executor: &(impl CommandExecutor + Sync),
1090    ) -> Result<Vec<CommandOutput>> {
1091        let mut outputs = Vec::with_capacity(self.commands.len());
1092        let mut index = 0;
1093        while index < self.commands.len() {
1094            let group = self.commands[index].parallel_group;
1095            let mut end = index + 1;
1096            if group.is_some() {
1097                while end < self.commands.len() && self.commands[end].parallel_group == group {
1098                    end += 1;
1099                }
1100            }
1101            let batch = &self.commands[index..end];
1102            if let [command] = batch {
1103                outputs.push(checked_command_output(command, executor.execute(command)?)?);
1104            } else {
1105                let results: Vec<Result<CommandOutput>> = std::thread::scope(|scope| {
1106                    let handles: Vec<_> = batch
1107                        .iter()
1108                        .map(|command| scope.spawn(|| executor.execute(command)))
1109                        .collect();
1110                    handles
1111                        .into_iter()
1112                        .map(|handle| match handle.join() {
1113                            Ok(result) => result,
1114                            Err(panic) => Err(anyhow::anyhow!(
1115                                "concurrent command thread panicked: {}",
1116                                command_thread_panic_message(panic.as_ref())
1117                            )),
1118                        })
1119                        .collect()
1120                });
1121                for (command, result) in batch.iter().zip(results) {
1122                    outputs.push(checked_command_output(command, result?)?);
1123                }
1124            }
1125            index = end;
1126        }
1127        Ok(outputs)
1128    }
1129
1130    /// Split the plan around the command that creates the session's target:
1131    /// the commands through that one, then the commands that run against a
1132    /// target which already exists.
1133    ///
1134    /// A plan that creates nothing — an existing project directory, say —
1135    /// splits into nothing, so a caller never arms a teardown for a target it
1136    /// did not bring into existence.
1137    pub fn split_at_target_creation(&self) -> Option<(Self, Self)> {
1138        let created = self
1139            .commands
1140            .iter()
1141            .position(|command| command.creates_target)?;
1142        let (creation, remainder) = self.commands.split_at(created + 1);
1143        Some((
1144            Self {
1145                description: self.description.clone(),
1146                commands: creation.to_vec(),
1147            },
1148            Self {
1149                description: self.description.clone(),
1150                commands: remainder.to_vec(),
1151            },
1152        ))
1153    }
1154}
1155
1156/// Fail the same way [`CommandPlan::execute`] does for a non-zero exit
1157/// status; kept as a shared helper so [`CommandPlan::execute_concurrent`]
1158/// reports identical error text.
1159pub fn checked_command_output(
1160    command: &CommandSpec,
1161    output: CommandOutput,
1162) -> Result<CommandOutput> {
1163    if output.status != 0 {
1164        bail!(
1165            "{} failed with status {}: {}",
1166            command.purpose,
1167            output.status,
1168            String::from_utf8_lossy(&output.stderr)
1169        );
1170    }
1171    Ok(output)
1172}
1173
1174/// Describe a spawned command thread's panic payload for error context.
1175pub fn command_thread_panic_message(payload: &(dyn std::any::Any + Send)) -> String {
1176    if let Some(message) = payload.downcast_ref::<&str>() {
1177        (*message).to_owned()
1178    } else if let Some(message) = payload.downcast_ref::<String>() {
1179        message.clone()
1180    } else {
1181        "non-string panic payload".to_owned()
1182    }
1183}
1184
1185#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1186pub struct RepositorySpec {
1187    /// Network clone URL. Managed workspaces require a configured remote.
1188    pub url: Option<String>,
1189    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1190    pub push_urls: Vec<String>,
1191    pub destination: String,
1192    pub git_ref: Option<String>,
1193    /// Read-only bare repository mounted into the target for Git object reuse.
1194    /// A missing or unusable reference is only an optimization miss.
1195    #[serde(default, skip_serializing_if = "Option::is_none")]
1196    pub reference: Option<String>,
1197}
1198
1199#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1200pub struct ProjectBundleSpec {
1201    pub primary: String,
1202    pub repositories: Vec<RepositorySpec>,
1203}
1204
1205impl ProjectBundleSpec {
1206    pub fn validate(&self) -> Result<()> {
1207        validate_relative_path(&self.primary)?;
1208        if self.repositories.is_empty() {
1209            bail!("a project bundle must contain at least one repository");
1210        }
1211        let mut destinations = std::collections::BTreeSet::new();
1212        for repository in &self.repositories {
1213            validate_relative_path(&repository.destination)?;
1214            ensure!(
1215                repository
1216                    .url
1217                    .as_deref()
1218                    .is_some_and(|url| !url.trim().is_empty() && !url.starts_with('-')),
1219                "isolated repositories require a network Git remote; configure a remote or use a raw local session"
1220            );
1221            crate::remote_git::validate_network_url(
1222                repository.url.as_deref().expect("checked above"),
1223            )?;
1224            for push_url in &repository.push_urls {
1225                crate::remote_git::validate_network_url(push_url)?;
1226            }
1227            ensure!(
1228                repository.git_ref.is_none(),
1229                "git_ref is no longer supported; remove it to start from the remote's default branch"
1230            );
1231            if !destinations.insert(&repository.destination) {
1232                bail!(
1233                    "duplicate repository destination {}",
1234                    repository.destination
1235                );
1236            }
1237        }
1238        if !destinations.contains(&self.primary) {
1239            bail!("primary repository is not present in the bundle");
1240        }
1241        Ok(())
1242    }
1243}
1244
1245#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1246#[serde(tag = "kind", rename_all = "snake_case")]
1247pub enum PodmanWorkspaceStorage {
1248    PodmanVolume,
1249    HostHelper {
1250        root: String,
1251        helper: Vec<String>,
1252    },
1253    #[default]
1254    ContainerLayer,
1255}
1256
1257#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1258pub struct ContainerTemplate {
1259    pub image: String,
1260    #[serde(default)]
1261    pub pull_policy: ImagePullPolicy,
1262    #[serde(default)]
1263    pub extra_run_args: Vec<String>,
1264    #[serde(default)]
1265    pub workspace_storage: PodmanWorkspaceStorage,
1266    /// Per-target mbx build cache overrides, carried from the user
1267    /// configuration so cache resolution can read them off a runtime target.
1268    #[serde(default)]
1269    pub build_cache: Option<crate::config::TargetBuildCache>,
1270}
1271
1272impl ImagePullPolicy {
1273    /// How fresh this target wants its image, with `Auto` read from the image
1274    /// reference. This is the freshness the background refresher acts on.
1275    pub fn resolve(self, image: &str) -> Self {
1276        if self != Self::Auto {
1277            return self;
1278        }
1279        if image_is_digest_pinned(image) {
1280            Self::Missing
1281        } else if image_is_remote(image) && image_uses_latest_tag(image) {
1282            Self::Newer
1283        } else {
1284            Self::Missing
1285        }
1286    }
1287
1288    /// How fresh a launch insists on being. `Auto` never pulls here: the daemon
1289    /// refreshes remote `:latest` images on its own schedule, so a session
1290    /// starts from the cached image instead of blocking a launch on a
1291    /// multi-gigabyte download. An explicit policy still means what it says.
1292    pub fn at_launch(self, image: &str) -> Self {
1293        if self == Self::Auto {
1294            Self::Missing
1295        } else {
1296            self.resolve(image)
1297        }
1298    }
1299
1300    /// What this policy does to `image`, in the words a settings screen can
1301    /// show. `Auto` is derived rather than an alias, so it names the launch
1302    /// behavior and the background refresh it implies, from the same image
1303    /// reading `resolve` uses.
1304    pub fn describe(self, image: &str) -> &'static str {
1305        match self {
1306            Self::Always => "Pull every launch",
1307            Self::Newer => "Pull when the registry is newer",
1308            Self::Missing => "Pull only if missing",
1309            Self::Never => "Never pull",
1310            Self::Auto => match self.resolve(image) {
1311                Self::Newer => "Pull if missing at launch; refresh :latest in background",
1312                _ => "Pull if missing",
1313            },
1314        }
1315    }
1316
1317    /// Podman's spelling of an already-resolved policy.
1318    pub fn podman_value(self) -> &'static str {
1319        match self {
1320            Self::Always => "always",
1321            Self::Newer => "newer",
1322            Self::Missing => "missing",
1323            Self::Never => "never",
1324            Self::Auto => unreachable!("auto pull policy must resolve"),
1325        }
1326    }
1327}
1328
1329/// Where a background image refresh runs.
1330///
1331/// The SSH form wraps commands the way provisioning does rather than the way
1332/// the preflight probes do: a pull runs for minutes, and the probes' two-second
1333/// keepalive would drop the connection underneath it.
1334#[derive(Debug, Clone, PartialEq, Eq)]
1335pub enum ImageHost {
1336    LocalPodman,
1337    LocalDocker,
1338    AppleContainer,
1339    SshPodman(SshTarget),
1340    SshDocker(SshTarget),
1341}
1342
1343impl ImageHost {
1344    pub const fn engine(&self) -> &'static str {
1345        match self {
1346            Self::LocalPodman | Self::SshPodman(_) => "podman",
1347            Self::LocalDocker | Self::SshDocker(_) => "docker",
1348            Self::AppleContainer => "container",
1349        }
1350    }
1351
1352    /// How this host is named in a log line.
1353    pub fn label(&self) -> String {
1354        match self {
1355            Self::LocalPodman => "local podman".to_owned(),
1356            Self::LocalDocker => "local docker".to_owned(),
1357            Self::AppleContainer => "apple container".to_owned(),
1358            Self::SshPodman(ssh) => format!("podman on {}", ssh.destination),
1359            Self::SshDocker(ssh) => format!("docker on {}", ssh.destination),
1360        }
1361    }
1362
1363    fn command(&self, args: Vec<String>, purpose: String) -> CommandSpec {
1364        match self {
1365            Self::LocalPodman | Self::LocalDocker | Self::AppleContainer => {
1366                CommandSpec::new(args[0].clone(), args[1..].iter().cloned())
1367            }
1368            Self::SshPodman(ssh) | Self::SshDocker(ssh) => ssh_command_owned(ssh, args),
1369        }
1370        .purpose(purpose)
1371    }
1372}
1373
1374/// When a background refresh downloads an image.
1375///
1376/// A host that already has the image is the common case, and most targets
1377/// only need the copy to exist. Ordering matters: merging two targets that
1378/// share an image takes the larger value, so one demanding target upgrades
1379/// the pair.
1380#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
1381pub enum RefreshWhen {
1382    /// Pull only when the host has no copy (missing, or auto on a fixed tag).
1383    WhenAbsent,
1384    /// Pull every tick (always, or newer / auto on a moving tag).
1385    Always,
1386}
1387
1388/// The commands that keep one host's copy of one image current, away from any
1389/// session launch. They run in this order, and only for that host.
1390#[derive(Debug, Clone, PartialEq, Eq)]
1391pub struct ImageRefresh {
1392    pub host: ImageHost,
1393    pub image: String,
1394    pub platform: Option<String>,
1395    /// Whether this image is downloaded only when the host lacks it, or on
1396    /// every refresh.
1397    pub when: RefreshWhen,
1398    /// Reads the cached image id, so a pull that changed nothing stays quiet.
1399    /// Run before and after the pull.
1400    pub image_id: CommandSpec,
1401    pub pull: CommandSpec,
1402    /// Dangling images only. Both engines keep an image a container still uses.
1403    /// `None` for Apple's `container` engine, which has no verified prune form.
1404    pub prune: Option<CommandSpec>,
1405}
1406
1407/// The background refresh for one configured container target, or `None` when
1408/// the target asked never to download the image in the background.
1409///
1410/// `never` is the only opt-out. Every other policy at least wants the host to
1411/// have a copy, so the daemon downloads a missing image once; `always` and
1412/// `newer` keep the hourly pull they always had.
1413pub fn image_refresh(
1414    host: ImageHost,
1415    image: &str,
1416    platform: Option<&str>,
1417    pull_policy: ImagePullPolicy,
1418) -> Option<ImageRefresh> {
1419    let when = match pull_policy.resolve(image) {
1420        ImagePullPolicy::Always | ImagePullPolicy::Newer => RefreshWhen::Always,
1421        ImagePullPolicy::Missing => RefreshWhen::WhenAbsent,
1422        ImagePullPolicy::Never => return None,
1423        ImagePullPolicy::Auto => unreachable!("auto pull policy must resolve"),
1424    };
1425    let engine = host.engine();
1426    // Apple's `container` CLI has no `--format` on `image inspect`: the exit
1427    // status is what says the image is present, and the printed description
1428    // stands in for the id when comparing before and after a pull.
1429    let apple = matches!(host, ImageHost::AppleContainer);
1430    let mut image_id_args = vec![engine.to_owned(), "image".to_owned(), "inspect".to_owned()];
1431    if !apple {
1432        image_id_args.push("--format".to_owned());
1433        image_id_args.push("{{.Id}}".to_owned());
1434    }
1435    image_id_args.push(image.to_owned());
1436    let image_id = host.command(
1437        image_id_args,
1438        format!("read the cached id of container image {image}"),
1439    );
1440    let mut pull_args = vec![engine.to_owned()];
1441    if apple {
1442        pull_args.push("image".to_owned());
1443    }
1444    pull_args.push("pull".to_owned());
1445    // Apple's engine runs only native images, so it takes no platform.
1446    if let Some(platform) = platform.filter(|_| !apple) {
1447        pull_args.push(format!("--platform={platform}"));
1448    }
1449    pull_args.push(image.to_owned());
1450    let pull = host.command(pull_args, format!("refresh container image {image}"));
1451    let prune = (!apple).then(|| {
1452        host.command(
1453            vec![
1454                engine.to_owned(),
1455                "image".to_owned(),
1456                "prune".to_owned(),
1457                "-f".to_owned(),
1458            ],
1459            "remove dangling container images".to_owned(),
1460        )
1461    });
1462    Some(ImageRefresh {
1463        host,
1464        image: image.to_owned(),
1465        platform: platform.map(str::to_owned),
1466        when,
1467        image_id,
1468        pull,
1469        prune,
1470    })
1471}
1472
1473fn image_is_digest_pinned(image: &str) -> bool {
1474    image
1475        .rsplit_once('@')
1476        .is_some_and(|(_, digest)| !digest.is_empty())
1477}
1478
1479fn image_is_remote(image: &str) -> bool {
1480    !image.starts_with("localhost/") && !image.starts_with("local/")
1481}
1482
1483fn image_uses_latest_tag(image: &str) -> bool {
1484    let name = image.split_once('@').map_or(image, |(name, _)| name);
1485    let final_component = name.rsplit('/').next().unwrap_or(name);
1486    !final_component.contains(':') || final_component.ends_with(":latest")
1487}
1488
1489#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1490pub struct SshTarget {
1491    pub destination: String,
1492    #[serde(default)]
1493    pub ssh_args: Vec<String>,
1494}
1495
1496#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1497pub struct AwsTemplate {
1498    pub profile: String,
1499    pub region: String,
1500    pub launch_template: String,
1501    pub launch_template_version: Option<String>,
1502    pub instance_type: Option<String>,
1503    pub ssh: SshTarget,
1504}
1505
1506#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1507#[serde(tag = "kind", rename_all = "snake_case")]
1508pub enum TargetTemplate {
1509    LocalBare,
1510    LocalPodman(ContainerTemplate),
1511    LocalDocker(ContainerTemplate),
1512    AppleContainer(ContainerTemplate),
1513    AwsEc2(AwsTemplate),
1514    SshBare {
1515        ssh: SshTarget,
1516        #[serde(default = "default_ssh_prefix")]
1517        workspace_prefix: String,
1518    },
1519    SshPodman {
1520        ssh: SshTarget,
1521        container: ContainerTemplate,
1522    },
1523    SshDocker {
1524        ssh: SshTarget,
1525        container: ContainerTemplate,
1526    },
1527}
1528
1529fn default_ssh_prefix() -> String {
1530    ".local/share/hel/workspaces".to_owned()
1531}
1532
1533#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1534#[serde(tag = "kind", rename_all = "snake_case")]
1535pub enum PodmanWorkspaceLocator {
1536    #[default]
1537    ContainerLayer,
1538    Volume {
1539        name: String,
1540    },
1541    HostPath {
1542        path: String,
1543        helper: Vec<String>,
1544        resource: String,
1545    },
1546}
1547
1548#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1549#[serde(tag = "kind", rename_all = "snake_case")]
1550pub enum TargetLocator {
1551    LocalBare {
1552        worker_root: String,
1553    },
1554    LocalPodman {
1555        container_id: String,
1556        #[serde(default)]
1557        workspace_storage: PodmanWorkspaceLocator,
1558        /// The session that owns the container when this locator is a
1559        /// sub-agent child borrowing its parent's container; `None` when the
1560        /// session owns the container itself.
1561        #[serde(default, skip_serializing_if = "Option::is_none")]
1562        borrowed_from: Option<String>,
1563    },
1564    LocalDocker {
1565        container_id: String,
1566        /// The session that owns the container when this locator is a
1567        /// sub-agent child borrowing its parent's container; `None` when the
1568        /// session owns the container itself.
1569        #[serde(default, skip_serializing_if = "Option::is_none")]
1570        borrowed_from: Option<String>,
1571    },
1572    AppleContainer {
1573        container_id: String,
1574        /// The session that owns the container when this locator is a
1575        /// sub-agent child borrowing its parent's container; `None` when the
1576        /// session owns the container itself.
1577        #[serde(default, skip_serializing_if = "Option::is_none")]
1578        borrowed_from: Option<String>,
1579    },
1580    AwsEc2 {
1581        profile: String,
1582        region: String,
1583        instance_id: String,
1584        ssh: SshTarget,
1585        workspace: String,
1586    },
1587    SshBare {
1588        ssh: SshTarget,
1589        workspace: String,
1590        /// A borrowed-target child has its own worker identity in the parent's workspace.
1591        #[serde(default, skip_serializing_if = "Option::is_none")]
1592        worker_id: Option<String>,
1593    },
1594    SshPodman {
1595        ssh: SshTarget,
1596        container_id: String,
1597        #[serde(default)]
1598        workspace_storage: PodmanWorkspaceLocator,
1599        /// The session that owns the container when this locator is a
1600        /// sub-agent child borrowing its parent's container; `None` when the
1601        /// session owns the container itself.
1602        #[serde(default, skip_serializing_if = "Option::is_none")]
1603        borrowed_from: Option<String>,
1604    },
1605    SshDocker {
1606        ssh: SshTarget,
1607        container_id: String,
1608        /// The session that owns the container when this locator is a
1609        /// sub-agent child borrowing its parent's container; `None` when the
1610        /// session owns the container itself.
1611        #[serde(default, skip_serializing_if = "Option::is_none")]
1612        borrowed_from: Option<String>,
1613    },
1614}
1615
1616impl TargetTemplate {
1617    pub const fn container_engine(&self) -> Option<&'static str> {
1618        match self {
1619            Self::LocalPodman(_) | Self::SshPodman { .. } => Some("podman"),
1620            Self::LocalDocker(_) | Self::SshDocker { .. } => Some("docker"),
1621            Self::AppleContainer(_) => Some("container"),
1622            _ => None,
1623        }
1624    }
1625
1626    /// The host that downloads this target's image, with the container
1627    /// settings that name the image. `None` for targets that run no image.
1628    pub fn image_host(&self) -> Option<(ImageHost, &ContainerTemplate)> {
1629        match self {
1630            Self::LocalPodman(container) => Some((ImageHost::LocalPodman, container)),
1631            Self::LocalDocker(container) => Some((ImageHost::LocalDocker, container)),
1632            Self::AppleContainer(container) => Some((ImageHost::AppleContainer, container)),
1633            Self::SshPodman { ssh, container } => {
1634                Some((ImageHost::SshPodman(ssh.clone()), container))
1635            }
1636            Self::SshDocker { ssh, container } => {
1637                Some((ImageHost::SshDocker(ssh.clone()), container))
1638            }
1639            Self::LocalBare | Self::AwsEc2(_) | Self::SshBare { .. } => None,
1640        }
1641    }
1642}
1643
1644impl TargetLocator {
1645    /// The operating system the harness will run on. Only a bare localhost
1646    /// target runs it on this machine; every other locator is a Linux
1647    /// container or a Linux host reached over SSH.
1648    pub const fn harness_host(&self) -> HarnessHost {
1649        match self {
1650            Self::LocalBare { .. } => HarnessHost::current(),
1651            _ => HarnessHost::Other,
1652        }
1653    }
1654
1655    /// The target kind spelling shared with [`crate::config::TargetTemplate`].
1656    pub const fn kind_name(&self) -> &'static str {
1657        match self {
1658            Self::LocalBare { .. } => "local-bare",
1659            Self::LocalPodman { .. } => "local-podman",
1660            Self::LocalDocker { .. } => "local-docker",
1661            Self::AppleContainer { .. } => "apple-container",
1662            Self::AwsEc2 { .. } => "aws-ec2",
1663            Self::SshBare { .. } => "ssh-bare",
1664            Self::SshPodman { .. } => "ssh-podman",
1665            Self::SshDocker { .. } => "ssh-docker",
1666        }
1667    }
1668
1669    pub const fn container_engine(&self) -> Option<&'static str> {
1670        match self {
1671            Self::LocalPodman { .. } | Self::SshPodman { .. } => Some("podman"),
1672            Self::LocalDocker { .. } | Self::SshDocker { .. } => Some("docker"),
1673            Self::AppleContainer { .. } => Some("container"),
1674            _ => None,
1675        }
1676    }
1677}
1678
1679/// Commands and identity needed to bring a stopped managed target back online.
1680/// Only runtimes whose stopped resources retain their durable files provide
1681/// one; callers leave every other target kind alone.
1682#[derive(Debug, Clone, PartialEq, Eq)]
1683pub struct TargetRecoveryPlan {
1684    pub exists: CommandSpec,
1685    pub inspect: CommandSpec,
1686    pub start: CommandSpec,
1687    pub session_id: String,
1688}
1689
1690#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1691pub enum TargetRecoveryOutcome {
1692    NotRequired,
1693    Missing,
1694    AlreadyRunning,
1695    Started,
1696}
1697
1698pub fn resource_name(session_id: &str) -> Result<String> {
1699    validate_session_id(session_id)?;
1700    let readable: String = session_id
1701        .chars()
1702        .filter(|character| character.is_ascii_alphanumeric())
1703        .take(12)
1704        .map(|character| character.to_ascii_lowercase())
1705        .collect();
1706    let digest = Sha256::digest(session_id.as_bytes());
1707    Ok(format!(
1708        "mj-{readable}-{:02x}{:02x}{:02x}",
1709        digest[0], digest[1], digest[2]
1710    ))
1711}
1712
1713pub fn podman_workspace_locator(
1714    template: &ContainerTemplate,
1715    session_id: &str,
1716) -> Result<PodmanWorkspaceLocator> {
1717    let resource = format!("{}-workspace", resource_name(session_id)?);
1718    match &template.workspace_storage {
1719        PodmanWorkspaceStorage::PodmanVolume => {
1720            Ok(PodmanWorkspaceLocator::Volume { name: resource })
1721        }
1722        PodmanWorkspaceStorage::HostHelper { root, helper } => {
1723            let root = Path::new(root);
1724            ensure!(
1725                root.is_absolute(),
1726                "Podman workspace storage root must be absolute"
1727            );
1728            ensure!(
1729                !helper.is_empty() && helper.iter().all(|argument| !argument.is_empty()),
1730                "Podman workspace storage helper must contain non-empty arguments"
1731            );
1732            Ok(PodmanWorkspaceLocator::HostPath {
1733                path: root.join(&resource).to_string_lossy().into_owned(),
1734                helper: helper.clone(),
1735                resource,
1736            })
1737        }
1738        PodmanWorkspaceStorage::ContainerLayer => Ok(PodmanWorkspaceLocator::ContainerLayer),
1739    }
1740}
1741
1742/// The in-container workspace root a session's repositories live under.
1743///
1744/// `recorded` is the session record's `container_workspace`. A session whose
1745/// container predates per-session workspaces has none and keeps the legacy
1746/// shared `/workspace`; every session created since records
1747/// `/workspace/<session id>`, so a build cache shared by every container on a
1748/// host never sees two checkouts of one project at the same absolute path.
1749pub fn container_workspace_root(recorded: Option<&Path>) -> String {
1750    recorded.map_or_else(
1751        || CONTAINER_WORKSPACE.to_owned(),
1752        |path| path.to_string_lossy().into_owned(),
1753    )
1754}
1755
1756/// The per-session container workspace recorded for a session created now.
1757pub fn new_container_workspace(session_id: &str) -> Result<PathBuf> {
1758    validate_session_id(session_id)?;
1759    Ok(Path::new(CONTAINER_WORKSPACE).join(session_id))
1760}
1761
1762/// The workspace directory an EC2 session owns in its login home.
1763///
1764/// EC2 instances are provisioned per session, so the path is decided by the
1765/// session id alone and is the same whether it is read from a target template
1766/// or rebuilt from a stored locator.
1767pub fn aws_workspace(session_id: &str) -> String {
1768    format!(".local/share/hel/workspaces/{session_id}")
1769}
1770
1771pub fn workspace_for(template: &TargetTemplate, session_id: &str) -> Result<String> {
1772    validate_session_id(session_id)?;
1773    match template {
1774        TargetTemplate::LocalBare => bail!("local bare projects use their selected directory"),
1775        // A container workspace is per session and recorded on the session
1776        // record, so it is read with `container_workspace_root` instead.
1777        TargetTemplate::LocalPodman(_)
1778        | TargetTemplate::LocalDocker(_)
1779        | TargetTemplate::AppleContainer(_)
1780        | TargetTemplate::SshPodman { .. }
1781        | TargetTemplate::SshDocker { .. } => {
1782            bail!("container targets use the session's recorded container workspace")
1783        }
1784        TargetTemplate::AwsEc2(_) => Ok(aws_workspace(session_id)),
1785        TargetTemplate::SshBare {
1786            workspace_prefix, ..
1787        } => {
1788            validate_workspace_prefix(workspace_prefix)?;
1789            // Interpret a leading "~/" as home-relative. Remote commands are
1790            // single-quoted, so a literal tilde would name a directory called
1791            // "~"; a relative path resolves against the login home for ssh
1792            // and scp alike.
1793            let prefix = workspace_prefix
1794                .strip_prefix("~/")
1795                .unwrap_or(workspace_prefix);
1796            Ok(format!("{}/{session_id}", prefix.trim_end_matches('/')))
1797        }
1798    }
1799}
1800
1801/// Wrap an argv vector for execution at a provisioned session target.
1802pub fn command_on_locator(
1803    locator: &TargetLocator,
1804    session_id: &str,
1805    args: Vec<String>,
1806    purpose: impl Into<String>,
1807) -> Result<CommandSpec> {
1808    verify_locator(locator, session_id)?;
1809    if args.is_empty() {
1810        bail!("target command must not be empty");
1811    }
1812    Ok(locator_command(locator, args).purpose(purpose))
1813}
1814
1815/// Wrap a non-empty argv vector for execution at a target, without checking
1816/// that the locator belongs to a particular session. Every per-target command
1817/// is built here, so each target kind is spelled out once.
1818pub fn locator_command(locator: &TargetLocator, args: Vec<String>) -> CommandSpec {
1819    match locator {
1820        TargetLocator::LocalBare { .. } => {
1821            let mut args = args.into_iter();
1822            let program = args.next().expect("target command must not be empty");
1823            CommandSpec::new(program, args)
1824        }
1825        TargetLocator::LocalPodman { container_id, .. }
1826        | TargetLocator::LocalDocker { container_id, .. }
1827        | TargetLocator::AppleContainer { container_id, .. } => container_exec(
1828            locator.container_engine().expect("local container"),
1829            container_id,
1830            args,
1831        ),
1832        TargetLocator::AwsEc2 { ssh, .. } | TargetLocator::SshBare { ssh, .. } => {
1833            ssh_command_owned(ssh, args)
1834        }
1835        TargetLocator::SshPodman {
1836            ssh, container_id, ..
1837        }
1838        | TargetLocator::SshDocker {
1839            ssh, container_id, ..
1840        } => {
1841            let mut remote = vec![
1842                locator
1843                    .container_engine()
1844                    .expect("remote container")
1845                    .to_owned(),
1846                "exec".to_owned(),
1847                "-i".to_owned(),
1848                container_id.to_owned(),
1849            ];
1850            remote.extend(args);
1851            ssh_command_owned(ssh, remote)
1852        }
1853    }
1854}
1855pub fn worker_root(locator: &TargetLocator, session_id: &str) -> Result<String> {
1856    verify_locator(locator, session_id)?;
1857    Ok(match locator {
1858        TargetLocator::LocalBare { worker_root } => worker_root.clone(),
1859        TargetLocator::LocalPodman { .. }
1860        | TargetLocator::LocalDocker { .. }
1861        | TargetLocator::AppleContainer { .. }
1862        | TargetLocator::SshPodman { .. }
1863        | TargetLocator::SshDocker { .. } => format!("/var/lib/hel/workers/{session_id}"),
1864        TargetLocator::AwsEc2 { .. } => format!(".local/share/hel/workers/{session_id}"),
1865        TargetLocator::SshBare { worker_id, .. } => format!(
1866            ".local/share/hel/workers/{}",
1867            worker_id.as_deref().unwrap_or(session_id)
1868        ),
1869    })
1870}
1871mod convert;
1872pub use convert::{StoredTarget, TargetConversionError, ssh_args_with_identity};
1873
1874mod ssh;
1875pub use ssh::*;
1876
1877pub fn container_exec(
1878    engine: &str,
1879    container_id: &str,
1880    args: impl IntoIterator<Item = impl Into<String>>,
1881) -> CommandSpec {
1882    let mut command_args = vec!["exec".to_owned(), "-i".to_owned(), container_id.to_owned()];
1883    command_args.extend(args.into_iter().map(Into::into));
1884    CommandSpec::new(engine, command_args)
1885}
1886
1887#[cfg(all(test, unix))]
1888mod executor_tests {
1889    use std::fs;
1890
1891    use super::*;
1892
1893    /// A stand-in for `ssh` that is refused by the server on its first call and
1894    /// connects on the next, the way a host at its `MaxStartups` ceiling
1895    /// behaves once the daemon's burst drains.
1896    fn flaky_ssh_script(directory: &Path) -> CommandSpec {
1897        let counter = directory.join("attempts");
1898        let script = format!(
1899            "count=$(cat {counter} 2>/dev/null || echo 0)\n\
1900             echo $((count + 1)) > {counter}\n\
1901             if [ \"$count\" -eq 0 ]; then\n\
1902             echo 'kex_exchange_identification: Connection closed by 10.0.0.1 port 22' >&2\n\
1903             exit 255\n\
1904             fi\n\
1905             echo connected\n",
1906            counter = counter.display()
1907        );
1908        CommandSpec::new("sh", ["-c".to_owned(), script])
1909            .ssh_destination("build@10.0.0.1")
1910            .purpose("run the flaky SSH fixture")
1911    }
1912
1913    fn attempts(directory: &Path) -> u32 {
1914        fs::read_to_string(directory.join("attempts"))
1915            .expect("the fixture records its attempts")
1916            .trim()
1917            .parse()
1918            .expect("attempt count is a number")
1919    }
1920
1921    #[test]
1922    fn a_transport_rejected_ssh_command_is_retried_once_and_then_succeeds() {
1923        set_ssh_retry_backoff_for_test(Some(Duration::from_millis(5)));
1924        let directory = tempfile::tempdir().expect("temp dir");
1925        let command = flaky_ssh_script(directory.path());
1926
1927        let output = ProcessExecutor
1928            .execute(&command)
1929            .expect("the retry must reach the successful attempt");
1930
1931        assert_eq!(output.status, 0);
1932        assert_eq!(String::from_utf8_lossy(&output.stdout).trim(), "connected");
1933        assert_eq!(attempts(directory.path()), 2);
1934        set_ssh_retry_backoff_for_test(None);
1935    }
1936
1937    #[test]
1938    fn an_untagged_command_is_not_retried_after_the_same_failure() {
1939        set_ssh_retry_backoff_for_test(Some(Duration::from_millis(5)));
1940        let directory = tempfile::tempdir().expect("temp dir");
1941        let mut command = flaky_ssh_script(directory.path());
1942        command.ssh_destination = None;
1943
1944        let output = ProcessExecutor.execute(&command).expect("runs once");
1945
1946        assert_eq!(output.status, 255);
1947        assert_eq!(attempts(directory.path()), 1);
1948        set_ssh_retry_backoff_for_test(None);
1949    }
1950
1951    #[test]
1952    fn the_cancellable_executor_also_retries_a_transport_rejection() {
1953        set_ssh_retry_backoff_for_test(Some(Duration::from_millis(5)));
1954        let directory = tempfile::tempdir().expect("temp dir");
1955        let command = flaky_ssh_script(directory.path());
1956
1957        let output = CancellableProcessExecutor::new(Arc::new(AtomicBool::new(false)))
1958            .execute(&command)
1959            .expect("the retry must reach the successful attempt");
1960
1961        assert_eq!(output.status, 0);
1962        assert_eq!(attempts(directory.path()), 2);
1963        set_ssh_retry_backoff_for_test(None);
1964    }
1965}