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::{HarnessKind, ImagePullPolicy};
20
21mod diagnostics;
22pub use diagnostics::{BlockingOperation, BlockingOperationSnapshot, active_blocking_operations};
23pub mod storage;
24
25pub const SESSION_LABEL: &str = "dev.mj.session";
26pub const MANAGED_LABEL: &str = "dev.mj.managed";
27pub const SESSION_TAG: &str = "dev.mj.session";
28pub const MANAGED_TAG: &str = "dev.mj.managed";
29/// Which Mjolnir instance (named `--instance` or data-directory fingerprint)
30/// created a worker; see `config::instance_identity`.
31pub const INSTANCE_LABEL: &str = "dev.mj.instance";
32pub const INSTANCE_TAG: &str = "dev.mj.instance";
33/// The shared in-container workspace every container session used before
34/// per-session workspaces existed. New sessions record a path under it; see
35/// `container_workspace_root`.
36pub const CONTAINER_WORKSPACE: &str = "/workspace";
37
38/// The launch phase a command belongs to, reported as launch progress.
39#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
40pub enum ProvisionStage {
41    /// Waiting for a background download of this target's image to finish,
42    /// rather than starting a second download of the same image.
43    PullingImage,
44    Provisioning,
45    Booting,
46    Cloning,
47    Syncing,
48    Restoring,
49    Starting,
50    Installing(HarnessKind),
51    PreparingHarness {
52        harness: HarnessKind,
53        step: String,
54        since_ms: i64,
55    },
56    Compacting,
57    RecoveryCopy,
58    Verifying,
59    Closing,
60    StoppingTarget,
61    RemovingContainer,
62    RemovingStorage,
63    CleaningCache,
64}
65
66impl ProvisionStage {
67    pub fn label(&self) -> String {
68        match self {
69            Self::PullingImage => "Pull image".into(),
70            Self::Provisioning => "Provision".into(),
71            Self::Booting => "Boot".into(),
72            Self::Cloning => "Clone".into(),
73            Self::Syncing => "Sync".into(),
74            Self::Restoring => "Restore".into(),
75            Self::Starting => "Start".into(),
76            Self::Installing(harness) => format!("Installing {}", harness.display_name()),
77            Self::PreparingHarness { harness, step, .. } => {
78                format!("Preparing {}: {step}", harness.display_name())
79            }
80            Self::Compacting => "Compact".into(),
81            Self::RecoveryCopy => "Recovery copy".into(),
82            Self::Verifying => "Verify".into(),
83            Self::Closing => "Shut down agent".into(),
84            Self::StoppingTarget => "Stop target".into(),
85            Self::RemovingContainer => "Remove container".into(),
86            Self::RemovingStorage => "Remove container storage".into(),
87            Self::CleaningCache => "Clean cache".into(),
88        }
89    }
90
91    /// The worker owns the start time for harness preparation. Other stages
92    /// start when the controller observes them.
93    pub fn started_at_epoch_seconds(&self) -> Option<u64> {
94        match self {
95            Self::PreparingHarness { since_ms, .. } => {
96                Some(u64::try_from((*since_ms).max(0) / 1_000).unwrap_or_default())
97            }
98            _ => None,
99        }
100    }
101}
102
103#[derive(Clone, PartialEq, Eq)]
104struct SensitiveCommandInput(Vec<u8>);
105
106impl std::fmt::Debug for SensitiveCommandInput {
107    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
108        formatter.write_str("<redacted>")
109    }
110}
111
112#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
113pub struct CommandSpec {
114    pub program: String,
115    pub args: Vec<String>,
116    #[serde(default)]
117    pub env: BTreeMap<String, String>,
118    /// Replace ambient process variables with the explicitly supplied environment.
119    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
120    pub clear_env: bool,
121    #[serde(default, skip_serializing_if = "Option::is_none")]
122    pub cwd: Option<std::path::PathBuf>,
123    pub purpose: String,
124    #[serde(default)]
125    pub stage: Option<ProvisionStage>,
126    /// Commands that share this marker and appear consecutively in a plan's
127    /// command list may run concurrently under
128    /// [`CommandPlan::execute_concurrent`]. Commands without a marker, or
129    /// whose neighbors do not share it, keep running strictly in plan order.
130    #[serde(default)]
131    pub parallel_group: Option<u32>,
132    /// Whether this command brings the session's target into existence. Every
133    /// command after it in a provisioning plan runs against a target that
134    /// already exists, so a later failure owes that target's teardown.
135    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
136    pub creates_target: bool,
137    /// This command deliberately leaves a process running after it exits: it
138    /// starts a detached worker, whose lifetime belongs to the session rather
139    /// than to the launch. The executor still gives the command its own
140    /// process group, and cancellation still signals that group, but the
141    /// successful completion of a launch must not kill what it launched.
142    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
143    pub detaches: bool,
144    /// The SSH destination this command opens a connection to, when it does.
145    /// Tagged commands pass through [`SshAdmission`] so the daemon never
146    /// exceeds the remote `sshd`'s `MaxStartups` budget, and a transport
147    /// rejection is retried rather than reported as a command failure.
148    #[serde(default, skip_serializing_if = "Option::is_none")]
149    pub ssh_destination: Option<String>,
150    /// The shared SSH connection this command runs on as one session, when it
151    /// does. Just before spawning, the executor leases a session on one of the
152    /// connection's masters with [`SshSessions::lease`] and puts the options
153    /// that bind the command to that master in front of its arguments (see
154    /// [`CommandSpec::open_ssh_session`]). The arguments stored here never
155    /// contain them.
156    #[serde(default, skip_serializing_if = "Option::is_none")]
157    pub ssh_session: Option<SshTarget>,
158    /// The session is a fail-fast probe: it is counted on a shard but never
159    /// opens a master (see [`CommandSpec::ssh_probe_session`]).
160    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
161    pub ssh_session_probe: bool,
162    /// Input that must reach the child without becoming part of its arguments,
163    /// environment, serialized plan, or debug representation.
164    #[serde(skip)]
165    sensitive_stdin: Option<SensitiveCommandInput>,
166}
167
168impl CommandSpec {
169    pub fn new(
170        program: impl Into<String>,
171        args: impl IntoIterator<Item = impl Into<String>>,
172    ) -> Self {
173        Self {
174            program: program.into(),
175            args: args.into_iter().map(Into::into).collect(),
176            env: BTreeMap::new(),
177            clear_env: false,
178            cwd: None,
179            purpose: String::new(),
180            stage: None,
181            parallel_group: None,
182            creates_target: false,
183            detaches: false,
184            ssh_destination: None,
185            ssh_session: None,
186            ssh_session_probe: false,
187            sensitive_stdin: None,
188        }
189    }
190
191    pub fn purpose(mut self, purpose: impl Into<String>) -> Self {
192        self.purpose = purpose.into();
193        self
194    }
195
196    pub fn stage(mut self, stage: ProvisionStage) -> Self {
197        self.stage = Some(stage);
198        self
199    }
200
201    /// Mark this command as eligible to run concurrently with its
202    /// plan-adjacent siblings that share the same group.
203    pub fn parallel_group(mut self, group: u32) -> Self {
204        self.parallel_group = Some(group);
205        self
206    }
207
208    /// Record that this command opens an `ssh` connection to `destination`,
209    /// which is the host (or `user@host`) argument, never an option value.
210    pub fn ssh_destination(mut self, destination: impl Into<String>) -> Self {
211        self.ssh_destination = Some(destination.into());
212        self
213    }
214
215    /// Record that this command is an `ssh` or `scp` invocation that runs as
216    /// one session on `ssh`'s shared connection. It also opens a connection
217    /// to that destination, for admission, when sharing is off.
218    pub fn ssh_session(mut self, ssh: &SshTarget) -> Self {
219        self.ssh_destination = Some(ssh.destination.clone());
220        self.ssh_session = Some(ssh.clone());
221        self
222    }
223
224    /// Record that this command is a fail-fast `ssh` probe, such as a target
225    /// validation, that joins `ssh`'s shared connection without ever opening
226    /// a master. Its short timeouts and keepalives belong to the probe alone:
227    /// a master opened by it would impose them on every later session. The
228    /// probe still uses one of the master's sessions while it runs, so the
229    /// executor counts it in the session ledger like any other session.
230    pub fn ssh_probe_session(mut self, ssh: &SshTarget) -> Self {
231        self = self.ssh_session(ssh);
232        self.ssh_session_probe = true;
233        self
234    }
235
236    /// Lease the SSH session this command asks for and return the command as
237    /// it must be spawned, with the lease that must outlive the child.
238    ///
239    /// A command without a session request is returned unchanged. `executor`
240    /// runs the master check and, when needed, the opener.
241    pub fn open_ssh_session(&self, executor: &dyn CommandExecutor) -> Result<SessionCommand<'_>> {
242        let Some(ssh) = &self.ssh_session else {
243            return Ok(SessionCommand {
244                command: std::borrow::Cow::Borrowed(self),
245                lease: None,
246            });
247        };
248        let _waiting = BlockingOperation::start(
249            &format!(
250                "lease SSH connection to {} for {}",
251                ssh.destination, self.purpose
252            ),
253            "ssh",
254        );
255        let lease = if self.ssh_session_probe {
256            SshSessions::lease_probe(ssh)
257        } else {
258            SshSessions::lease(ssh, executor)?
259        };
260        let mut command = self.clone();
261        command.ssh_session = None;
262        command.ssh_session_probe = false;
263        command.args = session_command_args(&self.program, &self.args, ssh, &lease);
264        Ok(SessionCommand {
265            command: std::borrow::Cow::Owned(command),
266            lease: Some(lease),
267        })
268    }
269
270    /// Mark this command as the one that creates the session's target.
271    pub fn creates_target(mut self) -> Self {
272        self.creates_target = true;
273        self
274    }
275
276    /// Feed private file content through the shared concurrent pipe handler.
277    /// The bytes stay out of argv, environments, serialization, and Debug.
278    pub fn with_sensitive_stdin(mut self, input: Vec<u8>) -> Self {
279        self.sensitive_stdin = Some(SensitiveCommandInput(input));
280        self
281    }
282}
283
284#[cfg(test)]
285mod podman_exec_tests {
286    use super::*;
287
288    #[test]
289    fn podman_exec_keeps_the_container_configured_user() {
290        assert_eq!(
291            CommandSpec::new("podman", ["exec", "--detach", "session", "true"]).args,
292            ["exec", "--detach", "session", "true"]
293        );
294        assert_eq!(
295            CommandSpec::new("podman", ["exec", "--user", "1000:1000", "session", "id"]).args,
296            ["exec", "--user", "1000:1000", "session", "id"]
297        );
298        assert_eq!(
299            CommandSpec::new("podman", ["exec", "session", "tool", "--user", "42"]).args,
300            ["exec", "session", "tool", "--user", "42"]
301        );
302        assert_eq!(
303            CommandSpec::new("docker", ["exec", "session", "id"]).args,
304            ["exec", "session", "id"]
305        );
306    }
307}
308
309/// A command ready to spawn, together with the SSH session lease it runs on.
310/// Keep this value alive until the child has exited: dropping it frees the
311/// session slot.
312#[derive(Debug)]
313pub struct SessionCommand<'a> {
314    command: std::borrow::Cow<'a, CommandSpec>,
315    lease: Option<SshSessionLease>,
316}
317
318impl SessionCommand<'_> {
319    pub fn command(&self) -> &CommandSpec {
320        &self.command
321    }
322
323    pub fn lease(&self) -> Option<&SshSessionLease> {
324        self.lease.as_ref()
325    }
326
327    pub fn into_parts(self) -> (CommandSpec, Option<SshSessionLease>) {
328        (self.command.into_owned(), self.lease)
329    }
330}
331
332#[derive(Debug, Clone, PartialEq, Eq)]
333pub struct CommandOutput {
334    pub status: i32,
335    pub stdout: Vec<u8>,
336    pub stderr: Vec<u8>,
337}
338
339#[derive(Debug, Clone, Copy, PartialEq, Eq)]
340pub enum DeploymentCapacityKind {
341    Host,
342    AwsFleet,
343}
344
345#[derive(Debug, Clone, PartialEq, Eq)]
346pub struct DeploymentCapacityTarget {
347    pub id: String,
348    pub host: String,
349    pub target_ids: Vec<String>,
350    pub kind: DeploymentCapacityKind,
351    pub local: bool,
352    /// Alternative commands for a host, or one command per live AWS instance.
353    pub probes: Vec<CommandSpec>,
354    /// Prevents a partial AWS fleet sample when one live instance cannot be probed yet.
355    pub probe_error: Option<String>,
356}
357
358#[derive(Debug, Clone, PartialEq, Eq)]
359pub struct DeploymentCapacityUsage {
360    pub cpu_percent: Option<u8>,
361    pub memory_used_bytes: u64,
362    pub memory_total_bytes: u64,
363    pub logical_cores: u64,
364    pub disk_total_bytes: Option<u64>,
365    /// Free space on the filesystems Mjolnir writes to, per machine probed:
366    /// one for a host, one per instance for a fleet.
367    pub storage: Vec<storage::HostStorageSample>,
368}
369
370/// An additional directory made available to one session.
371///
372/// Containers use isolated mounts. Remote targets may instead receive a
373/// controller-packed snapshot at the destination while retaining this shared
374/// persisted shape.
375#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
376#[serde(from = "AdditionalMountRepr", into = "AdditionalMountRepr")]
377pub struct AdditionalMount {
378    pub source: PathBuf,
379    pub destination: PathBuf,
380    pub access: MountAccess,
381}
382
383/// How a container sees an attached directory.
384#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
385#[serde(rename_all = "snake_case")]
386pub enum MountAccess {
387    /// The container can read the directory but not change it.
388    Ro,
389    /// The container writes to a private copy-on-write overlay; the host
390    /// directory never changes.
391    Cow,
392    /// The container writes straight through to the host directory.
393    Rw,
394}
395
396impl MountAccess {
397    pub const ALL: [Self; 3] = [Self::Ro, Self::Cow, Self::Rw];
398
399    pub fn label(self) -> &'static str {
400        match self {
401            Self::Ro => "ro",
402            Self::Cow => "cow",
403            Self::Rw => "rw",
404        }
405    }
406
407    /// The mode this attachment takes where the host filesystem cannot carry
408    /// a copy-on-write overlay. Read-only is the substitute, because the
409    /// alternative would let the container write through to the host
410    /// directory the user asked to keep unchanged.
411    ///
412    /// This is the one rule for an unavailable overlay: the wizard offers the
413    /// same modes [`Self::offered`] lists, and the runtime downgrades a stored
414    /// mount the same way.
415    pub fn without_overlay(self) -> Self {
416        match self {
417            Self::Cow => Self::Ro,
418            kept => kept,
419        }
420    }
421
422    /// The modes an attachment may be given, given whether the host filesystem
423    /// can carry the copy-on-write overlay.
424    pub fn offered(overlay_available: bool) -> Vec<Self> {
425        Self::ALL
426            .into_iter()
427            .filter(|access| overlay_available || access.without_overlay() == *access)
428            .collect()
429    }
430}
431
432/// The persisted shape. Read-only and copy-on-write mounts keep the original
433/// `read_only` boolean alone, so archives and API payloads that older builds
434/// read stay unchanged; only read-write adds `access`, which older builds
435/// cannot represent.
436#[derive(Serialize, Deserialize)]
437#[serde(deny_unknown_fields)]
438struct AdditionalMountRepr {
439    source: PathBuf,
440    destination: PathBuf,
441    #[serde(default)]
442    read_only: bool,
443    #[serde(default, skip_serializing_if = "Option::is_none")]
444    access: Option<MountAccess>,
445}
446
447impl From<AdditionalMountRepr> for AdditionalMount {
448    fn from(repr: AdditionalMountRepr) -> Self {
449        let access = repr.access.unwrap_or(if repr.read_only {
450            MountAccess::Ro
451        } else {
452            MountAccess::Cow
453        });
454        Self {
455            source: repr.source,
456            destination: repr.destination,
457            access,
458        }
459    }
460}
461
462impl From<AdditionalMount> for AdditionalMountRepr {
463    fn from(mount: AdditionalMount) -> Self {
464        Self {
465            source: mount.source,
466            destination: mount.destination,
467            read_only: mount.access == MountAccess::Ro,
468            access: (mount.access == MountAccess::Rw).then_some(MountAccess::Rw),
469        }
470    }
471}
472
473/// Why a filesystem cannot host a container target's copy-on-write overlay,
474/// or `None` when it can. Unknown types are allowed: the overlay is the better
475/// mount and only a filesystem known to break it is downgraded.
476///
477/// The names are those `stat -f -c %T` reports, matched case-insensitively.
478pub fn overlay_unsupported_filesystem(filesystem: &str) -> Option<&'static str> {
479    let name = filesystem.trim().to_ascii_lowercase();
480    // FUSE reports the backing driver as `fuse.sshfs`, `fuse.s3fs`, and so on.
481    if name == "fuse" || name == "fuseblk" || name.starts_with("fuse.") {
482        return Some("FUSE filesystem");
483    }
484    match name.as_str() {
485        "nfs" | "nfs4" | "cifs" | "smb2" | "smb3" | "9p" | "v9fs" | "virtiofs" | "ceph"
486        | "lustre" | "afs" | "glusterfs" | "ocfs2" | "gfs" | "gfs2" => Some("network filesystem"),
487        "msdos" | "vfat" | "fat" | "exfat" | "ntfs" | "ntfs3" => Some("no POSIX metadata"),
488        "overlayfs" => Some("overlay stacking limit"),
489        _ => None,
490    }
491}
492
493/// Container destinations cannot use the controller or login user's home.
494pub fn validate_mount_destination(path: &Path) -> Result<()> {
495    ensure!(
496        path.is_absolute()
497            && !path
498                .components()
499                .any(|part| part == std::path::Component::ParentDir),
500        "additional mount destination must be a safe absolute container path; ~ is not supported"
501    );
502    Ok(())
503}
504
505pub fn validate_additional_mounts(mounts: &[AdditionalMount]) -> Result<()> {
506    let mut destinations = BTreeSet::new();
507    for mount in mounts {
508        if !mount.source.is_absolute() || mount.source.as_os_str().is_empty() {
509            bail!("additional mount source must be an absolute directory path");
510        }
511        validate_mount_destination(&mount.destination)?;
512        if !destinations.insert(mount.destination.clone()) {
513            bail!(
514                "additional mount destination {:?} is configured more than once",
515                mount.destination
516            );
517        }
518    }
519    Ok(())
520}
521
522/// Choose the editable default destination for an additional host directory.
523pub fn default_mount_destination(source: &Path, existing: &[AdditionalMount]) -> PathBuf {
524    let basename = source
525        .file_name()
526        .filter(|name| !name.is_empty())
527        .unwrap_or_else(|| std::ffi::OsStr::new("mount"));
528    let base = PathBuf::from("/mnt").join(basename);
529    if !existing.iter().any(|mount| mount.destination == base) {
530        return base;
531    }
532    for number in 2.. {
533        let candidate =
534            PathBuf::from("/mnt").join(format!("{}-{number}", basename.to_string_lossy()));
535        if !existing.iter().any(|mount| mount.destination == candidate) {
536            return candidate;
537        }
538    }
539    unreachable!("a finite mount list always has an unused numbered destination")
540}
541
542pub trait CommandExecutor {
543    fn execute(&self, command: &CommandSpec) -> Result<CommandOutput>;
544
545    /// Bound cleanup attempts while preserving supervisor cancellation. Durable
546    /// owners retry interrupted cleanup under the next lifecycle or daemon.
547    fn execute_cleanup(&self, command: &CommandSpec) -> Result<CommandOutput> {
548        self.execute(command)
549    }
550
551    /// Whether the operation supervising this executor has requested
552    /// cancellation. Test executors and ordinary process execution are not
553    /// cancellable unless they opt in.
554    fn cancellation_requested(&self) -> bool {
555        false
556    }
557
558    /// Report entry into a lifecycle stage. Callers that cover more than one
559    /// command should hold a [`ProvisionStageGuard`] for the whole operation
560    /// so concurrent stages remain visible between subprocesses.
561    fn stage_started(&self, _stage: ProvisionStage) {}
562
563    /// Report exit from a lifecycle stage previously passed to
564    /// [`Self::stage_started`].
565    fn stage_finished(&self, _stage: ProvisionStage) {}
566
567    /// Report a decision an operation made on the user's behalf. This is not a
568    /// failure: the work continues, and the user is told what changed.
569    fn notify_notice(&self, _notice: &str) {}
570
571    /// Stop the source's children only once destination preparation has succeeded.
572    fn before_move_source_stop(
573        &self,
574    ) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<()>> + Send + '_>> {
575        Box::pin(async { Ok(()) })
576    }
577
578    /// The Move source has been sealed; its destination belongs exclusively
579    /// to the lifecycle until queue admission completes.
580    fn reserve_move_destination(&self) {}
581
582    /// The durable Move owns this interruptible copy; daemon handoff may resume it.
583    fn begin_resumable_move_work(&self) -> Result<()> {
584        Ok(())
585    }
586    fn end_resumable_move_work(&self) -> Result<()> {
587        Ok(())
588    }
589
590    fn execute_with_stdin(
591        &self,
592        _command: &CommandSpec,
593        _input: &mut (dyn Read + Send),
594    ) -> Result<CommandOutput> {
595        bail!("this command executor does not support streamed stdin")
596    }
597}
598
599/// A scoped lifecycle-stage report for controller-side work or a sequence of
600/// commands. Dropping the guard reports completion even when the work returns
601/// early with an error.
602pub struct ProvisionStageGuard<'a, E: CommandExecutor + ?Sized> {
603    executor: &'a E,
604    stage: ProvisionStage,
605}
606
607impl<'a, E: CommandExecutor + ?Sized> ProvisionStageGuard<'a, E> {
608    pub fn new(executor: &'a E, stage: ProvisionStage) -> Self {
609        executor.stage_started(stage.clone());
610        Self { executor, stage }
611    }
612}
613
614impl<E: CommandExecutor + ?Sized> Drop for ProvisionStageGuard<'_, E> {
615    fn drop(&mut self) {
616        self.executor.stage_finished(self.stage.clone());
617    }
618}
619
620pub struct ProcessExecutor;
621
622/// Run one `ssh` invocation under process-wide admission control, retrying it
623/// when the server turned the connection away before authentication.
624///
625/// A permit is held only while a child is actually running and is released
626/// between attempts, because a waiting retry occupies no connection slot. A
627/// transport rejection means the remote command never started, so re-running
628/// the whole invocation cannot repeat a side effect.
629///
630/// A command that asks for a shared-connection session leases it on each
631/// attempt, before taking its permit: opening a master takes a permit of its
632/// own, and waiting for that while holding one could exhaust the gate. A
633/// session turned away by the transport usually means its master died, so
634/// the lease is invalidated and the retry checks and reopens the master.
635///
636/// Commands that are not tagged with a destination run untouched.
637fn with_ssh_admission(
638    command: &CommandSpec,
639    executor: &dyn CommandExecutor,
640    is_cancelled: &dyn Fn() -> bool,
641    mut run: impl FnMut(&CommandSpec) -> Result<CommandOutput>,
642) -> Result<CommandOutput> {
643    let Some(destination) = command.ssh_destination.as_deref() else {
644        return run(command);
645    };
646    for attempt in 1..=SSH_RETRY_ATTEMPTS {
647        let session = command.open_ssh_session(executor)?;
648        let output = {
649            let _permit = SshAdmission::acquire_unless(destination, is_cancelled)?;
650            run(session.command())?
651        };
652        let refusal = ssh_refusal(output.status, &String::from_utf8_lossy(&output.stderr));
653        // A refused session found its master alive; any other refusal may
654        // mean the master is gone.
655        if refusal == Some(SshRefusal::BeforeAuthentication)
656            && let Some(lease) = session.lease()
657        {
658            lease.invalidate();
659        }
660        drop(session);
661        let Some(refusal) = refusal else {
662            return Ok(output);
663        };
664        let stderr = String::from_utf8_lossy(&output.stderr);
665        if attempt == SSH_RETRY_ATTEMPTS {
666            refusal.log_exhausted(destination, &command.purpose, stderr.trim());
667            return Ok(output);
668        }
669        let delay = ssh_retry_delay(attempt);
670        refusal.log_retry(destination, &command.purpose, attempt, delay, stderr.trim());
671        if !sleep_unless_cancelled(delay, is_cancelled) {
672            bail!("operation cancelled while {}", command.purpose);
673        }
674    }
675    unreachable!("the final attempt always returns");
676}
677
678/// Wait out `delay`, giving up early if the supervising operation is
679/// cancelled. Returns whether the wait completed.
680fn sleep_unless_cancelled(delay: Duration, is_cancelled: &dyn Fn() -> bool) -> bool {
681    let deadline = Instant::now() + delay;
682    loop {
683        if is_cancelled() {
684            return false;
685        }
686        let remaining = deadline.saturating_duration_since(Instant::now());
687        if remaining.is_zero() {
688            return true;
689        }
690        std::thread::sleep(remaining.min(Duration::from_millis(50)));
691    }
692}
693
694/// Every process executor finishes a command here: one debug line, so a slow
695/// launch or resume phase can be attributed from logs instead of re-profiled
696/// by hand, and one look at a failure for a full disk on the target.
697fn finished_command(
698    command: &CommandSpec,
699    started: Instant,
700    status: i32,
701    stdout: Vec<u8>,
702    stderr: Vec<u8>,
703) -> CommandOutput {
704    tracing::debug!(
705        purpose = command.purpose.as_str(),
706        program = command.program.as_str(),
707        status,
708        elapsed_ms = started.elapsed().as_millis() as u64,
709        "target command finished"
710    );
711    let output = CommandOutput {
712        status,
713        stdout,
714        stderr,
715    };
716    storage::observe_command_output(command, &output);
717    output
718}
719
720impl ProcessExecutor {
721    /// One attempt, with no admission or retry of its own.
722    fn run_once(&self, command: &CommandSpec) -> Result<CommandOutput> {
723        if let Some(input) = &command.sensitive_stdin {
724            let mut input = std::io::Cursor::new(input.0.as_slice());
725            // Owned bytes, so each attempt gets its own reader.
726            return stream_command_with_stdin(
727                cancellable_command(command),
728                command,
729                &mut input,
730                &|| false,
731            );
732        }
733        let started = Instant::now();
734        let output = configured_command(command)
735            .stdin(Stdio::null())
736            .output()
737            .with_context(|| format!("run {} for {}", command.program, command.purpose))?;
738        let status = output.status.code().unwrap_or(-1);
739        Ok(finished_command(
740            command,
741            started,
742            status,
743            output.stdout,
744            output.stderr,
745        ))
746    }
747}
748
749// Both process executors wait for their child, and for SSH admission before
750// it, through `off_async_worker`: a caller several calls below an `async fn`
751// must not hold one of the runtime's async worker threads for the length of a
752// target command.
753impl CommandExecutor for ProcessExecutor {
754    fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
755        crate::runtime::off_async_worker(|| {
756            let _running = BlockingOperation::command(command);
757            with_ssh_admission(command, self, &|| false, |command| self.run_once(command))
758        })
759    }
760
761    fn execute_with_stdin(
762        &self,
763        command: &CommandSpec,
764        input: &mut (dyn Read + Send),
765    ) -> Result<CommandOutput> {
766        crate::runtime::off_async_worker(|| {
767            let _running = BlockingOperation::command(command);
768            // A caller's stream cannot be replayed, so this path takes a
769            // session and a permit but never retries.
770            let session = command.open_ssh_session(self)?;
771            let _permit = command
772                .ssh_destination
773                .as_deref()
774                .map(SshAdmission::acquire);
775            let command = session.command();
776            let process = cancellable_command(command);
777            // Plain process execution is not cancellable, so the transfer only
778            // ends when the child does.
779            stream_command_with_stdin(process, command, input, &|| false)
780        })
781    }
782}
783
784/// Streams `input` into a freshly spawned child and collects its output.
785///
786/// Both executors share this one implementation because the pipe edge cases
787/// below are easy to get subtly wrong in a second copy.
788///
789/// `is_cancelled` reports whether the supervising operation wants the transfer
790/// abandoned; [`ProcessExecutor`] passes a check that is never true, which also
791/// makes the kill path below unreachable for it.
792fn stream_command_with_stdin(
793    mut process: Command,
794    command: &CommandSpec,
795    input: &mut (dyn Read + Send),
796    is_cancelled: &(dyn Fn() -> bool + Sync),
797) -> Result<CommandOutput> {
798    let started = Instant::now();
799    if is_cancelled() {
800        bail!("operation cancelled");
801    }
802    let mut child = process
803        .stdin(Stdio::piped())
804        .stdout(Stdio::piped())
805        .stderr(Stdio::piped())
806        .spawn()
807        .with_context(|| format!("run {} for {}", command.program, command.purpose))?;
808    let group =
809        (!command.detaches).then(|| crate::subprocess::ProcessGroupGuard::new(Some(child.id())));
810    let stdin = child
811        .stdin
812        .take()
813        .context("streamed command stdin missing")?;
814    let stdout = child
815        .stdout
816        .take()
817        .context("streamed command stdout missing")?;
818    let stderr = child
819        .stderr
820        .take()
821        .context("streamed command stderr missing")?;
822    // Reader threads keep the child's output pipes drained; a child that fills
823    // one while nobody reads would block instead of exiting.
824    let stdout_reader = PipeCollector::spawn(stdout);
825    let stderr_reader = PipeCollector::spawn(stderr);
826    let process_result = std::thread::scope(|scope| -> Result<_> {
827        // Pipe writes can block forever when a remote helper stops reading.
828        // Keep the writer off the supervising thread so cancellation can kill
829        // the process group and thereby close the blocked pipe.
830        let input_writer = scope.spawn(move || -> Result<()> {
831            // Owning `stdin` here is what closes the pipe's write end once the
832            // transfer finishes. A child that reads to EOF, such as
833            // `mj worker export-checkpoint --spec -`, never exits while any
834            // copy of the write end is still open.
835            let mut stdin = stdin;
836            let mut buffer = [0_u8; 64 * 1024];
837            loop {
838                // Checking before each chunk makes large checkpoint copies
839                // cooperatively cancellable without changing the executor
840                // interface.
841                if is_cancelled() {
842                    bail!("operation cancelled");
843                }
844                let count = input.read(&mut buffer).context("read command input")?;
845                if count == 0 {
846                    break;
847                }
848                stdin
849                    .write_all(&buffer[..count])
850                    .context("stream command input")?;
851            }
852            stdin.flush().context("flush command input")
853        });
854        let mut status = None;
855        let mut exited_at = None;
856        let mut group_killed = false;
857        let status = loop {
858            if is_cancelled() {
859                terminate_cancellable_child(&mut child);
860                if let Err(error) = input_writer.join() {
861                    tracing::warn!(
862                        purpose = command.purpose.as_str(),
863                        "streamed command input writer panicked while cancelling: {error:?}"
864                    );
865                }
866                bail!("operation cancelled while {}", command.purpose);
867            }
868            match if status.is_some() {
869                Ok(status)
870            } else {
871                child.try_wait()
872            } {
873                Ok(observed) => status = observed,
874                Err(error) => {
875                    terminate_cancellable_child(&mut child);
876                    if let Err(join_error) = input_writer.join() {
877                        tracing::warn!(
878                            purpose = command.purpose.as_str(),
879                            "streamed command input writer panicked while waiting: {join_error:?}"
880                        );
881                    }
882                    return Err(error).with_context(|| format!("wait for {}", command.purpose));
883                }
884            }
885            // The command completes when its leader exits. Descendants can
886            // retain the pipes, so output is collected for a bounded time;
887            // then the owned group is terminated, which also releases a
888            // writer blocked on a pipe a descendant holds.
889            if let Some(status) = status {
890                let exited_at = *exited_at.get_or_insert_with(Instant::now);
891                let drained = (stdout_reader.is_finished() && stderr_reader.is_finished())
892                    || exited_at.elapsed() >= IO_DRAIN_TIMEOUT;
893                if drained && !input_writer.is_finished() && !group_killed {
894                    group_killed = true;
895                    if let Some(group) = &group {
896                        group.kill();
897                    }
898                }
899                if drained && input_writer.is_finished() {
900                    break status;
901                }
902            }
903            std::thread::sleep(Duration::from_millis(25));
904        };
905        let input_result = input_writer
906            .join()
907            .map_err(|_| anyhow::anyhow!("streamed command input writer panicked"))?;
908        Ok((status, input_result))
909    });
910    let deadline = Instant::now();
911    let stdout = stdout_reader.finish("stdout", deadline)?;
912    let stderr = stderr_reader.finish("stderr", deadline)?;
913    let (status, input_result) = process_result?;
914    drop(group);
915    if status.success() {
916        // A child that exited first explains the failure through its own
917        // status and stderr; the broken pipe that exit caused would only hide
918        // it. A successful child must not hide an input error.
919        input_result?;
920    }
921    let status = status.code().unwrap_or(-1);
922    Ok(finished_command(command, started, status, stdout, stderr))
923}
924
925#[derive(Clone)]
926pub struct CancellableProcessExecutor {
927    cancelled: Arc<AtomicBool>,
928    deadline: Option<Instant>,
929}
930
931/// Cancels synchronous executor work when its supervising async wait is dropped.
932pub struct ProcessCancellationGuard(Arc<AtomicBool>);
933
934impl Drop for ProcessCancellationGuard {
935    fn drop(&mut self) {
936        self.0.store(true, Ordering::Release);
937    }
938}
939
940impl CancellableProcessExecutor {
941    pub fn cancel_on_drop(&self) -> ProcessCancellationGuard {
942        ProcessCancellationGuard(self.cancelled.clone())
943    }
944
945    pub fn new(cancelled: Arc<AtomicBool>) -> Self {
946        Self {
947            cancelled,
948            deadline: None,
949        }
950    }
951
952    pub fn is_cancelled(&self) -> bool {
953        self.cancelled.load(Ordering::Acquire)
954            || self
955                .deadline
956                .is_some_and(|deadline| Instant::now() >= deadline)
957    }
958
959    pub fn with_timeout(timeout: Duration) -> Self {
960        Self {
961            cancelled: Arc::new(AtomicBool::new(false)),
962            deadline: Some(Instant::now() + timeout),
963        }
964    }
965
966    /// Bounds an existing flag-based executor with a deadline, so a wedged
967    /// child becomes a reported failure instead of running forever.
968    pub fn with_deadline(mut self, timeout: Duration) -> Self {
969        self.deadline = Some(Instant::now() + timeout);
970        self
971    }
972
973    fn check_cancelled(&self) -> Result<()> {
974        if self.is_cancelled() {
975            bail!("operation cancelled");
976        }
977        Ok(())
978    }
979}
980
981fn configured_command(command: &CommandSpec) -> Command {
982    let mut process = Command::new(&command.program);
983    if command.clear_env {
984        process.env_clear();
985    }
986    if let Some(cwd) = &command.cwd {
987        process.current_dir(cwd);
988    }
989    process.args(&command.args).envs(&command.env);
990    process
991}
992
993fn cancellable_command(command: &CommandSpec) -> Command {
994    #[cfg(unix)]
995    let mut process = configured_command(command);
996    #[cfg(not(unix))]
997    let process = configured_command(command);
998    #[cfg(unix)]
999    {
1000        use std::os::unix::process::CommandExt as _;
1001        process.process_group(0);
1002    }
1003    process
1004}
1005
1006/// How long a command's output may keep arriving after its leader exits.
1007/// A backgrounded descendant inherits the output pipes and can hold them open
1008/// for its whole life, although the command it was started by is finished.
1009/// The same bound as Codex's `IO_DRAIN_TIMEOUT_MS`. The owned process group is
1010/// terminated once this drain ends.
1011const IO_DRAIN_TIMEOUT: Duration = Duration::from_secs(2);
1012
1013/// A child pipe that can be waited on with a timeout, so its reader can be
1014/// stopped while a descendant still holds the write end.
1015trait PollablePipe: Read + Send + 'static {
1016    fn readable(&self, timeout: Duration) -> bool;
1017}
1018
1019macro_rules! pollable_pipe {
1020    ($pipe:ty) => {
1021        impl PollablePipe for $pipe {
1022            #[cfg(unix)]
1023            fn readable(&self, timeout: Duration) -> bool {
1024                use std::os::fd::AsRawFd as _;
1025                let mut poll = libc::pollfd {
1026                    fd: self.as_raw_fd(),
1027                    events: libc::POLLIN,
1028                    revents: 0,
1029                };
1030                let millis = i32::try_from(timeout.as_millis()).unwrap_or(i32::MAX);
1031                // SAFETY: `poll` is one initialized pollfd for an open descriptor.
1032                unsafe { libc::poll(&raw mut poll, 1, millis) > 0 }
1033            }
1034
1035            // Without polling, a read blocks until the pipe closes.
1036            #[cfg(not(unix))]
1037            fn readable(&self, _timeout: Duration) -> bool {
1038                true
1039            }
1040        }
1041    };
1042}
1043
1044pollable_pipe!(std::process::ChildStdout);
1045pollable_pipe!(std::process::ChildStderr);
1046
1047/// Collects one child pipe on its own thread. Stopping it drops the thread's
1048/// end of the pipe, so a descendant that keeps the pipe open cannot hold the
1049/// reader past the drain.
1050struct PipeCollector {
1051    bytes: Arc<std::sync::Mutex<Vec<u8>>>,
1052    stop: Arc<AtomicBool>,
1053    thread: Option<std::thread::JoinHandle<std::io::Result<()>>>,
1054}
1055
1056impl PipeCollector {
1057    fn spawn(mut pipe: impl PollablePipe) -> Self {
1058        let bytes = Arc::new(std::sync::Mutex::new(Vec::new()));
1059        let stop = Arc::new(AtomicBool::new(false));
1060        let (collected, stopped) = (bytes.clone(), stop.clone());
1061        let thread = std::thread::spawn(move || {
1062            let mut chunk = [0_u8; 8192];
1063            while !stopped.load(Ordering::Acquire) {
1064                if !pipe.readable(Duration::from_millis(25)) {
1065                    continue;
1066                }
1067                match pipe.read(&mut chunk) {
1068                    Ok(0) => break,
1069                    Ok(count) => collected
1070                        .lock()
1071                        .unwrap_or_else(std::sync::PoisonError::into_inner)
1072                        .extend_from_slice(&chunk[..count]),
1073                    Err(error) if error.kind() == std::io::ErrorKind::Interrupted => {}
1074                    Err(error) => return Err(error),
1075                }
1076            }
1077            Ok(())
1078        });
1079        Self {
1080            bytes,
1081            stop,
1082            thread: Some(thread),
1083        }
1084    }
1085
1086    fn is_finished(&self) -> bool {
1087        self.thread
1088            .as_ref()
1089            .is_none_or(std::thread::JoinHandle::is_finished)
1090    }
1091
1092    /// Waits for end of stream until `deadline`, then stops the reader and
1093    /// returns everything it collected.
1094    fn finish(mut self, stream: &str, deadline: Instant) -> Result<Vec<u8>> {
1095        while !self.is_finished() && Instant::now() < deadline {
1096            std::thread::sleep(Duration::from_millis(5));
1097        }
1098        self.stop.store(true, Ordering::Release);
1099        self.thread
1100            .take()
1101            .context("command reader already joined")?
1102            .join()
1103            .map_err(|_| anyhow::anyhow!("command {stream} reader panicked"))?
1104            .with_context(|| format!("read command {stream}"))?;
1105        let mut bytes = self
1106            .bytes
1107            .lock()
1108            .unwrap_or_else(std::sync::PoisonError::into_inner);
1109        Ok(std::mem::take(&mut bytes))
1110    }
1111}
1112
1113impl Drop for PipeCollector {
1114    fn drop(&mut self) {
1115        self.stop.store(true, Ordering::Release);
1116    }
1117}
1118
1119fn terminate_cancellable_child(child: &mut std::process::Child) {
1120    #[cfg(unix)]
1121    // The child owns a fresh process group, so descendants such as an SSH or
1122    // shell helper cannot keep its output pipes open after cancellation. A
1123    // group that is already gone is the wanted outcome, not a failure, so the
1124    // shared helper decides what deserves a warning.
1125    if let Err(error) = crate::subprocess::signal_process_group(child.id() as i32, libc::SIGKILL) {
1126        tracing::warn!(pid = child.id(), %error, "could not terminate cancelled command process group");
1127    }
1128    #[cfg(not(unix))]
1129    if let Err(error) = child.kill() {
1130        tracing::warn!(pid = child.id(), %error, "could not terminate cancelled command");
1131    }
1132    if let Err(error) = child.wait() {
1133        tracing::warn!(pid = child.id(), %error, "could not reap cancelled command");
1134    }
1135}
1136
1137impl CancellableProcessExecutor {
1138    /// One attempt, with no admission or retry of its own.
1139    fn run_once(&self, command: &CommandSpec) -> Result<CommandOutput> {
1140        if let Some(input) = &command.sensitive_stdin {
1141            let mut input = std::io::Cursor::new(input.0.as_slice());
1142            // Owned bytes, so each attempt gets its own reader.
1143            return stream_command_with_stdin(
1144                cancellable_command(command),
1145                command,
1146                &mut input,
1147                &|| self.is_cancelled(),
1148            );
1149        }
1150        let started = Instant::now();
1151        self.check_cancelled()?;
1152        let mut child = cancellable_command(command)
1153            .stdin(Stdio::null())
1154            .stdout(Stdio::piped())
1155            .stderr(Stdio::piped())
1156            .spawn()
1157            .with_context(|| format!("run {} for {}", command.program, command.purpose))?;
1158        let group = (!command.detaches)
1159            .then(|| crate::subprocess::ProcessGroupGuard::new(Some(child.id())));
1160        let stdout = child.stdout.take().context("command stdout missing")?;
1161        let stderr = child.stderr.take().context("command stderr missing")?;
1162        let stdout_reader = PipeCollector::spawn(stdout);
1163        let stderr_reader = PipeCollector::spawn(stderr);
1164        let mut status = None;
1165        let mut exited_at = None;
1166        let status = loop {
1167            if self.is_cancelled() {
1168                terminate_cancellable_child(&mut child);
1169                let deadline = Instant::now() + IO_DRAIN_TIMEOUT;
1170                for (stream, reader) in [("stdout", stdout_reader), ("stderr", stderr_reader)] {
1171                    if let Err(error) = reader.finish(stream, deadline) {
1172                        tracing::warn!(stream, %error, "cancelled command reader failed");
1173                    }
1174                }
1175                bail!("operation cancelled while {}", command.purpose);
1176            }
1177            if status.is_none() {
1178                status = child
1179                    .try_wait()
1180                    .with_context(|| format!("wait for {}", command.purpose))?;
1181            }
1182            // The command completes when its leader exits. Descendants can
1183            // retain the pipes, so output is collected for a bounded time.
1184            if let Some(status) = status {
1185                let exited_at = *exited_at.get_or_insert_with(Instant::now);
1186                if (stdout_reader.is_finished() && stderr_reader.is_finished())
1187                    || exited_at.elapsed() >= IO_DRAIN_TIMEOUT
1188                {
1189                    break status;
1190                }
1191            }
1192            std::thread::sleep(Duration::from_millis(25));
1193        };
1194        let deadline = Instant::now();
1195        let stdout = stdout_reader.finish("stdout", deadline)?;
1196        let stderr = stderr_reader.finish("stderr", deadline)?;
1197        let status = status.code().unwrap_or(-1);
1198        drop(group);
1199        Ok(finished_command(command, started, status, stdout, stderr))
1200    }
1201}
1202
1203impl CommandExecutor for CancellableProcessExecutor {
1204    fn execute_cleanup(&self, command: &CommandSpec) -> Result<CommandOutput> {
1205        self.clone()
1206            .with_deadline(Duration::from_secs(15))
1207            .execute(command)
1208    }
1209
1210    fn cancellation_requested(&self) -> bool {
1211        self.is_cancelled()
1212    }
1213
1214    fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
1215        crate::runtime::off_async_worker(|| {
1216            let _running = BlockingOperation::command(command);
1217            with_ssh_admission(command, self, &|| self.is_cancelled(), |command| {
1218                self.run_once(command)
1219            })
1220        })
1221    }
1222
1223    fn execute_with_stdin(
1224        &self,
1225        command: &CommandSpec,
1226        input: &mut (dyn Read + Send),
1227    ) -> Result<CommandOutput> {
1228        crate::runtime::off_async_worker(|| {
1229            let _running = BlockingOperation::command(command);
1230            // A caller's stream cannot be replayed, so this path takes a
1231            // session and a permit but never retries.
1232            let session = command.open_ssh_session(self)?;
1233            let _permit = command
1234                .ssh_destination
1235                .as_deref()
1236                .map(|destination| {
1237                    SshAdmission::acquire_unless(destination, &|| self.is_cancelled())
1238                })
1239                .transpose()?;
1240            let command = session.command();
1241            // The child runs in its own process group so cancellation can kill
1242            // the whole group, which is what releases a writer blocked on a
1243            // full pipe.
1244            stream_command_with_stdin(cancellable_command(command), command, input, &|| {
1245                self.is_cancelled()
1246            })
1247        })
1248    }
1249}
1250
1251/// A command supervised by [`BoundedProcessExecutor`] did not finish before
1252/// its deadline. Callers downcast to this to tell a hung probe apart from a
1253/// command that could not be started at all.
1254#[derive(Debug, Clone, PartialEq, Eq)]
1255pub struct CommandTimedOut {
1256    pub program: String,
1257    pub purpose: String,
1258    pub timeout: Duration,
1259}
1260
1261impl std::fmt::Display for CommandTimedOut {
1262    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1263        write!(
1264            formatter,
1265            "`{}` did not answer within {} seconds while trying to {}",
1266            self.program,
1267            self.timeout.as_secs(),
1268            self.purpose
1269        )
1270    }
1271}
1272
1273impl std::error::Error for CommandTimedOut {}
1274
1275/// Runs every command with its own deadline.
1276///
1277/// [`CancellableProcessExecutor::with_timeout`] bounds a whole operation from a
1278/// single shared deadline, which suits one provisioning run. Prerequisite
1279/// probes are different: each one is expected to answer quickly, and a wedged
1280/// socket or blackholed network must not stall the probes that follow it. A
1281/// timeout here names the probe that hung, so the caller can report it the same
1282/// way it reports any other probe failure.
1283#[derive(Debug, Clone, Copy)]
1284pub struct BoundedProcessExecutor {
1285    timeout: Duration,
1286}
1287
1288impl BoundedProcessExecutor {
1289    pub const fn new(timeout: Duration) -> Self {
1290        Self { timeout }
1291    }
1292}
1293
1294impl CommandExecutor for BoundedProcessExecutor {
1295    fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
1296        let executor = CancellableProcessExecutor::with_timeout(self.timeout);
1297        executor.execute(command).map_err(|error| {
1298            if executor.is_cancelled() {
1299                anyhow::Error::new(CommandTimedOut {
1300                    program: command.program.clone(),
1301                    purpose: command.purpose.clone(),
1302                    timeout: self.timeout,
1303                })
1304            } else {
1305                error
1306            }
1307        })
1308    }
1309
1310    fn execute_with_stdin(
1311        &self,
1312        command: &CommandSpec,
1313        input: &mut (dyn Read + Send),
1314    ) -> Result<CommandOutput> {
1315        CancellableProcessExecutor::with_timeout(self.timeout).execute_with_stdin(command, input)
1316    }
1317}
1318
1319#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1320pub struct CommandPlan {
1321    pub description: String,
1322    pub commands: Vec<CommandSpec>,
1323}
1324
1325impl CommandPlan {
1326    /// Supply one container environment value without placing it in the
1327    /// Podman/SSH argument vector. The target launcher reads the value from
1328    /// stdin, exports it, and asks the container engine to inherit it by name.
1329    pub fn provide_target_environment_secret(
1330        &mut self,
1331        target: &TargetTemplate,
1332        name: &str,
1333        value: &str,
1334    ) -> Result<()> {
1335        ensure!(
1336            !name.is_empty()
1337                && name.bytes().enumerate().all(|(index, byte)| byte == b'_'
1338                    || byte.is_ascii_alphabetic()
1339                    || (index > 0 && byte.is_ascii_digit())),
1340            "invalid secret environment variable name"
1341        );
1342        ensure!(
1343            !value.as_bytes().contains(&b'\n') && !value.as_bytes().contains(&b'\r'),
1344            "secret environment value cannot contain a newline"
1345        );
1346        let command = self
1347            .commands
1348            .iter_mut()
1349            .find(|command| command.creates_target)
1350            .context("provisioning plan has no target creation command")?;
1351        let read_and_export = format!("IFS= read -r {name} || exit 1; export {name};");
1352        match target {
1353            TargetTemplate::LocalPodman(_)
1354            | TargetTemplate::LocalDocker(_)
1355            | TargetTemplate::AppleContainer(_) => {
1356                let program = std::mem::replace(&mut command.program, "sh".to_owned());
1357                let args = std::mem::take(&mut command.args);
1358                command.args = vec![
1359                    "-c".to_owned(),
1360                    format!("{read_and_export} exec \"$@\""),
1361                    "mj-secret-env".to_owned(),
1362                    program,
1363                ];
1364                command.args.extend(args);
1365            }
1366            TargetTemplate::SshPodman { .. } | TargetTemplate::SshDocker { .. } => {
1367                let remote = command
1368                    .args
1369                    .last_mut()
1370                    .context("remote container command has no SSH command argument")?;
1371                *remote = format!("{read_and_export} exec {remote}");
1372            }
1373            TargetTemplate::LocalBare
1374            | TargetTemplate::AwsEc2(_)
1375            | TargetTemplate::SshBare { .. } => {
1376                bail!("target does not support inherited container environment")
1377            }
1378        }
1379        let mut input = value.as_bytes().to_vec();
1380        input.push(b'\n');
1381        command.sensitive_stdin = Some(SensitiveCommandInput(input));
1382        Ok(())
1383    }
1384
1385    pub fn execute(&self, executor: &impl CommandExecutor) -> Result<Vec<CommandOutput>> {
1386        let mut outputs = Vec::with_capacity(self.commands.len());
1387        for command in &self.commands {
1388            let output = executor.execute(command)?;
1389            if output.status != 0 {
1390                bail!(
1391                    "{} failed with status {}: {}",
1392                    command.purpose,
1393                    output.status,
1394                    String::from_utf8_lossy(&output.stderr)
1395                );
1396            }
1397            outputs.push(output);
1398        }
1399        Ok(outputs)
1400    }
1401
1402    /// Execute the plan the same way [`Self::execute`] does, except that
1403    /// commands sharing a [`CommandSpec::parallel_group`] marker and
1404    /// appearing consecutively in `commands` run concurrently as one batch.
1405    ///
1406    /// A batch starts only once every earlier command has succeeded, and a
1407    /// batch that fails reports the first failure in plan order regardless
1408    /// of which command finished first — the same fail-fast contract
1409    /// [`Self::execute`] provides between individual commands. This method
1410    /// requires a `Sync` executor because a batch shares it across threads;
1411    /// [`Self::execute`] keeps working with non-`Sync` executors such as
1412    /// test fakes built on `RefCell`.
1413    pub fn execute_concurrent(
1414        &self,
1415        executor: &(impl CommandExecutor + Sync),
1416    ) -> Result<Vec<CommandOutput>> {
1417        let mut outputs = Vec::with_capacity(self.commands.len());
1418        let mut index = 0;
1419        while index < self.commands.len() {
1420            let group = self.commands[index].parallel_group;
1421            let mut end = index + 1;
1422            if group.is_some() {
1423                while end < self.commands.len() && self.commands[end].parallel_group == group {
1424                    end += 1;
1425                }
1426            }
1427            let batch = &self.commands[index..end];
1428            if let [command] = batch {
1429                outputs.push(checked_command_output(command, executor.execute(command)?)?);
1430            } else {
1431                let results: Vec<Result<CommandOutput>> = std::thread::scope(|scope| {
1432                    let handles: Vec<_> = batch
1433                        .iter()
1434                        .map(|command| scope.spawn(|| executor.execute(command)))
1435                        .collect();
1436                    handles
1437                        .into_iter()
1438                        .map(|handle| match handle.join() {
1439                            Ok(result) => result,
1440                            Err(panic) => Err(anyhow::anyhow!(
1441                                "concurrent command thread panicked: {}",
1442                                command_thread_panic_message(panic.as_ref())
1443                            )),
1444                        })
1445                        .collect()
1446                });
1447                for (command, result) in batch.iter().zip(results) {
1448                    outputs.push(checked_command_output(command, result?)?);
1449                }
1450            }
1451            index = end;
1452        }
1453        Ok(outputs)
1454    }
1455
1456    /// Split the plan around the command that creates the session's target:
1457    /// the commands through that one, then the commands that run against a
1458    /// target which already exists.
1459    ///
1460    /// A plan that creates nothing — an existing project directory, say —
1461    /// splits into nothing, so a caller never arms a teardown for a target it
1462    /// did not bring into existence.
1463    pub fn split_at_target_creation(&self) -> Option<(Self, Self)> {
1464        let created = self
1465            .commands
1466            .iter()
1467            .position(|command| command.creates_target)?;
1468        let (creation, remainder) = self.commands.split_at(created + 1);
1469        Some((
1470            Self {
1471                description: self.description.clone(),
1472                commands: creation.to_vec(),
1473            },
1474            Self {
1475                description: self.description.clone(),
1476                commands: remainder.to_vec(),
1477            },
1478        ))
1479    }
1480}
1481
1482/// Fail the same way [`CommandPlan::execute`] does for a non-zero exit
1483/// status; kept as a shared helper so [`CommandPlan::execute_concurrent`]
1484/// reports identical error text.
1485pub fn checked_command_output(
1486    command: &CommandSpec,
1487    output: CommandOutput,
1488) -> Result<CommandOutput> {
1489    if output.status != 0 {
1490        bail!(
1491            "{} failed with status {}: {}",
1492            command.purpose,
1493            output.status,
1494            String::from_utf8_lossy(&output.stderr)
1495        );
1496    }
1497    Ok(output)
1498}
1499
1500/// Describe a spawned command thread's panic payload for error context.
1501pub fn command_thread_panic_message(payload: &(dyn std::any::Any + Send)) -> String {
1502    if let Some(message) = payload.downcast_ref::<&str>() {
1503        (*message).to_owned()
1504    } else if let Some(message) = payload.downcast_ref::<String>() {
1505        message.clone()
1506    } else {
1507        "non-string panic payload".to_owned()
1508    }
1509}
1510
1511#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1512pub struct RepositorySpec {
1513    /// Network clone URL. Managed workspaces require a configured remote.
1514    pub url: Option<String>,
1515    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1516    pub push_urls: Vec<String>,
1517    pub destination: String,
1518    pub git_ref: Option<String>,
1519    /// Read-only bare repository mounted into the target for Git object reuse.
1520    /// A missing or unusable reference is only an optimization miss.
1521    #[serde(default, skip_serializing_if = "Option::is_none")]
1522    pub reference: Option<String>,
1523}
1524
1525#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1526pub struct ProjectBundleSpec {
1527    pub primary: String,
1528    pub repositories: Vec<RepositorySpec>,
1529}
1530
1531impl ProjectBundleSpec {
1532    pub fn validate(&self) -> Result<()> {
1533        validate_relative_path(&self.primary)?;
1534        if self.repositories.is_empty() {
1535            bail!("a project bundle must contain at least one repository");
1536        }
1537        let mut destinations = std::collections::BTreeSet::new();
1538        for repository in &self.repositories {
1539            validate_relative_path(&repository.destination)?;
1540            ensure!(
1541                repository
1542                    .url
1543                    .as_deref()
1544                    .is_some_and(|url| !url.trim().is_empty() && !url.starts_with('-')),
1545                "isolated repositories require a network Git remote; configure a remote or use a raw local session"
1546            );
1547            crate::remote_git::validate_network_url(
1548                repository.url.as_deref().expect("checked above"),
1549            )?;
1550            for push_url in &repository.push_urls {
1551                crate::remote_git::validate_network_url(push_url)?;
1552            }
1553            ensure!(
1554                repository.git_ref.is_none(),
1555                "git_ref is no longer supported; remove it to start from the remote's default branch"
1556            );
1557            if !destinations.insert(&repository.destination) {
1558                bail!(
1559                    "duplicate repository destination {}",
1560                    repository.destination
1561                );
1562            }
1563        }
1564        if !destinations.contains(&self.primary) {
1565            bail!("primary repository is not present in the bundle");
1566        }
1567        Ok(())
1568    }
1569}
1570
1571#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1572#[serde(tag = "kind", rename_all = "snake_case")]
1573pub enum PodmanWorkspaceStorage {
1574    PodmanVolume,
1575    HostHelper {
1576        root: String,
1577        helper: Vec<String>,
1578    },
1579    #[default]
1580    ContainerLayer,
1581}
1582
1583#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1584pub struct ContainerTemplate {
1585    pub image: String,
1586    #[serde(default)]
1587    pub pull_policy: ImagePullPolicy,
1588    #[serde(default)]
1589    pub extra_run_args: Vec<String>,
1590    #[serde(default)]
1591    pub workspace_storage: PodmanWorkspaceStorage,
1592    /// Per-target mbx build cache overrides, carried from the user
1593    /// configuration so cache resolution can read them off a runtime target.
1594    #[serde(default)]
1595    pub build_cache: Option<crate::config::TargetBuildCache>,
1596}
1597
1598impl ImagePullPolicy {
1599    /// How fresh this target wants its image, with `Auto` read from the image
1600    /// reference. This is the freshness the background refresher acts on.
1601    pub fn resolve(self, image: &str) -> Self {
1602        if self != Self::Auto {
1603            return self;
1604        }
1605        if image_is_digest_pinned(image) {
1606            Self::Missing
1607        } else if image_is_remote(image) && image_uses_latest_tag(image) {
1608            Self::Newer
1609        } else {
1610            Self::Missing
1611        }
1612    }
1613
1614    /// How fresh a launch insists on being. `Auto` never pulls here: the daemon
1615    /// refreshes remote `:latest` images on its own schedule, so a session
1616    /// starts from the cached image instead of blocking a launch on a
1617    /// multi-gigabyte download. An explicit policy still means what it says.
1618    pub fn at_launch(self, image: &str) -> Self {
1619        if self == Self::Auto {
1620            Self::Missing
1621        } else {
1622            self.resolve(image)
1623        }
1624    }
1625
1626    /// What this policy does to `image`, in the words a settings screen can
1627    /// show. `Auto` is derived rather than an alias, so it names the launch
1628    /// behavior and the background refresh it implies, from the same image
1629    /// reading `resolve` uses.
1630    pub fn describe(self, image: &str) -> &'static str {
1631        match self {
1632            Self::Always => "Pull every launch",
1633            Self::Newer => "Pull when the registry is newer",
1634            Self::Missing => "Pull only if missing",
1635            Self::Never => "Never pull",
1636            Self::Auto => match self.resolve(image) {
1637                Self::Newer => "Pull if missing at launch; refresh :latest in background",
1638                _ => "Pull if missing",
1639            },
1640        }
1641    }
1642
1643    /// Podman's spelling of an already-resolved policy.
1644    pub fn podman_value(self) -> &'static str {
1645        match self {
1646            Self::Always => "always",
1647            Self::Newer => "newer",
1648            Self::Missing => "missing",
1649            Self::Never => "never",
1650            Self::Auto => unreachable!("auto pull policy must resolve"),
1651        }
1652    }
1653}
1654
1655/// Where a background image refresh runs.
1656///
1657/// The SSH form wraps commands the way provisioning does rather than the way
1658/// the preflight probes do: a pull runs for minutes, and the probes' two-second
1659/// keepalive would drop the connection underneath it.
1660#[derive(Debug, Clone, PartialEq, Eq)]
1661pub enum ImageHost {
1662    LocalPodman,
1663    LocalDocker,
1664    AppleContainer,
1665    SshPodman(SshTarget),
1666    SshDocker(SshTarget),
1667}
1668
1669impl ImageHost {
1670    pub const fn engine(&self) -> &'static str {
1671        match self {
1672            Self::LocalPodman | Self::SshPodman(_) => "podman",
1673            Self::LocalDocker | Self::SshDocker(_) => "docker",
1674            Self::AppleContainer => "container",
1675        }
1676    }
1677
1678    /// How this host is named in a log line.
1679    pub fn label(&self) -> String {
1680        match self {
1681            Self::LocalPodman => "local podman".to_owned(),
1682            Self::LocalDocker => "local docker".to_owned(),
1683            Self::AppleContainer => "apple container".to_owned(),
1684            Self::SshPodman(ssh) => format!("podman on {}", ssh.destination),
1685            Self::SshDocker(ssh) => format!("docker on {}", ssh.destination),
1686        }
1687    }
1688
1689    fn command(&self, args: Vec<String>, purpose: String) -> CommandSpec {
1690        match self {
1691            Self::LocalPodman | Self::LocalDocker | Self::AppleContainer => {
1692                CommandSpec::new(args[0].clone(), args[1..].iter().cloned())
1693            }
1694            Self::SshPodman(ssh) | Self::SshDocker(ssh) => ssh_command_owned(ssh, args),
1695        }
1696        .purpose(purpose)
1697    }
1698}
1699
1700/// When a background refresh downloads an image.
1701///
1702/// A host that already has the image is the common case, and most targets
1703/// only need the copy to exist. Ordering matters: merging two targets that
1704/// share an image takes the larger value, so one demanding target upgrades
1705/// the pair.
1706#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
1707pub enum RefreshWhen {
1708    /// Pull only when the host has no copy (missing, or auto on a fixed tag).
1709    WhenAbsent,
1710    /// Pull every tick (always, or newer / auto on a moving tag).
1711    Always,
1712}
1713
1714/// The commands that keep one host's copy of one image current, away from any
1715/// session launch. They run in this order, and only for that host.
1716#[derive(Debug, Clone, PartialEq, Eq)]
1717pub struct ImageRefresh {
1718    pub host: ImageHost,
1719    pub image: String,
1720    pub platform: Option<String>,
1721    /// Whether this image is downloaded only when the host lacks it, or on
1722    /// every refresh.
1723    pub when: RefreshWhen,
1724    /// Reads the cached image id, so a pull that changed nothing stays quiet.
1725    /// Run before and after the pull.
1726    pub image_id: CommandSpec,
1727    pub pull: CommandSpec,
1728    /// Dangling images only. Both engines keep an image a container still uses.
1729    /// `None` for Apple's `container` engine, which has no verified prune form.
1730    pub prune: Option<CommandSpec>,
1731}
1732
1733/// The background refresh for one configured container target, or `None` when
1734/// the target asked never to download the image in the background.
1735///
1736/// `never` is the only opt-out. Every other policy at least wants the host to
1737/// have a copy, so the daemon downloads a missing image once; `always` and
1738/// `newer` keep the hourly pull they always had.
1739pub fn image_refresh(
1740    host: ImageHost,
1741    image: &str,
1742    platform: Option<&str>,
1743    pull_policy: ImagePullPolicy,
1744) -> Option<ImageRefresh> {
1745    let when = match pull_policy.resolve(image) {
1746        ImagePullPolicy::Always | ImagePullPolicy::Newer => RefreshWhen::Always,
1747        ImagePullPolicy::Missing => RefreshWhen::WhenAbsent,
1748        ImagePullPolicy::Never => return None,
1749        ImagePullPolicy::Auto => unreachable!("auto pull policy must resolve"),
1750    };
1751    let engine = host.engine();
1752    // Apple's `container` CLI has no `--format` on `image inspect`: the exit
1753    // status is what says the image is present, and the printed description
1754    // stands in for the id when comparing before and after a pull.
1755    let apple = matches!(host, ImageHost::AppleContainer);
1756    let mut image_id_args = vec![engine.to_owned(), "image".to_owned(), "inspect".to_owned()];
1757    if !apple {
1758        image_id_args.push("--format".to_owned());
1759        image_id_args.push("{{.Id}}".to_owned());
1760    }
1761    image_id_args.push(image.to_owned());
1762    let image_id = host.command(
1763        image_id_args,
1764        format!("read the cached id of container image {image}"),
1765    );
1766    let mut pull_args = vec![engine.to_owned()];
1767    if apple {
1768        pull_args.push("image".to_owned());
1769    }
1770    pull_args.push("pull".to_owned());
1771    // Apple's engine runs only native images, so it takes no platform.
1772    if let Some(platform) = platform.filter(|_| !apple) {
1773        pull_args.push(format!("--platform={platform}"));
1774    }
1775    pull_args.push(image.to_owned());
1776    let pull = host.command(pull_args, format!("refresh container image {image}"));
1777    let prune = (!apple).then(|| {
1778        host.command(
1779            vec![
1780                engine.to_owned(),
1781                "image".to_owned(),
1782                "prune".to_owned(),
1783                "-f".to_owned(),
1784            ],
1785            "remove dangling container images".to_owned(),
1786        )
1787    });
1788    Some(ImageRefresh {
1789        host,
1790        image: image.to_owned(),
1791        platform: platform.map(str::to_owned),
1792        when,
1793        image_id,
1794        pull,
1795        prune,
1796    })
1797}
1798
1799fn image_is_digest_pinned(image: &str) -> bool {
1800    image
1801        .rsplit_once('@')
1802        .is_some_and(|(_, digest)| !digest.is_empty())
1803}
1804
1805fn image_is_remote(image: &str) -> bool {
1806    !image.starts_with("localhost/") && !image.starts_with("local/")
1807}
1808
1809fn image_uses_latest_tag(image: &str) -> bool {
1810    let name = image.split_once('@').map_or(image, |(name, _)| name);
1811    let final_component = name.rsplit('/').next().unwrap_or(name);
1812    !final_component.contains(':') || final_component.ends_with(":latest")
1813}
1814
1815#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1816pub struct SshTarget {
1817    pub destination: String,
1818    #[serde(default)]
1819    pub ssh_args: Vec<String>,
1820}
1821
1822#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1823pub struct AwsTemplate {
1824    pub profile: String,
1825    pub region: String,
1826    pub launch_template: String,
1827    pub launch_template_version: Option<String>,
1828    pub instance_type: Option<String>,
1829    pub ssh: SshTarget,
1830}
1831
1832#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1833#[serde(tag = "kind", rename_all = "snake_case")]
1834pub enum TargetTemplate {
1835    LocalBare,
1836    LocalPodman(ContainerTemplate),
1837    LocalDocker(ContainerTemplate),
1838    AppleContainer(ContainerTemplate),
1839    AwsEc2(AwsTemplate),
1840    SshBare {
1841        ssh: SshTarget,
1842        #[serde(default = "default_ssh_prefix")]
1843        workspace_prefix: String,
1844    },
1845    SshPodman {
1846        ssh: SshTarget,
1847        container: ContainerTemplate,
1848    },
1849    SshDocker {
1850        ssh: SshTarget,
1851        container: ContainerTemplate,
1852    },
1853}
1854
1855fn default_ssh_prefix() -> String {
1856    ".local/share/hel/workspaces".to_owned()
1857}
1858
1859#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1860#[serde(tag = "kind", rename_all = "snake_case")]
1861pub enum PodmanWorkspaceLocator {
1862    #[default]
1863    ContainerLayer,
1864    Volume {
1865        name: String,
1866    },
1867    HostPath {
1868        path: String,
1869        helper: Vec<String>,
1870        resource: String,
1871    },
1872}
1873
1874#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1875#[serde(tag = "kind", rename_all = "snake_case")]
1876pub enum TargetLocator {
1877    LocalBare {
1878        worker_root: String,
1879    },
1880    LocalPodman {
1881        container_id: String,
1882        #[serde(default)]
1883        workspace_storage: PodmanWorkspaceLocator,
1884        /// The session that owns the container when this locator is a
1885        /// sub-agent child borrowing its parent's container; `None` when the
1886        /// session owns the container itself.
1887        #[serde(default, skip_serializing_if = "Option::is_none")]
1888        borrowed_from: Option<String>,
1889    },
1890    LocalDocker {
1891        container_id: String,
1892        /// The session that owns the container when this locator is a
1893        /// sub-agent child borrowing its parent's container; `None` when the
1894        /// session owns the container itself.
1895        #[serde(default, skip_serializing_if = "Option::is_none")]
1896        borrowed_from: Option<String>,
1897    },
1898    AppleContainer {
1899        container_id: String,
1900        /// The session that owns the container when this locator is a
1901        /// sub-agent child borrowing its parent's container; `None` when the
1902        /// session owns the container itself.
1903        #[serde(default, skip_serializing_if = "Option::is_none")]
1904        borrowed_from: Option<String>,
1905    },
1906    AwsEc2 {
1907        profile: String,
1908        region: String,
1909        instance_id: String,
1910        ssh: SshTarget,
1911        workspace: String,
1912    },
1913    SshBare {
1914        ssh: SshTarget,
1915        workspace: String,
1916        /// A borrowed-target child has its own worker identity in the parent's workspace.
1917        #[serde(default, skip_serializing_if = "Option::is_none")]
1918        worker_id: Option<String>,
1919    },
1920    SshPodman {
1921        ssh: SshTarget,
1922        container_id: String,
1923        #[serde(default)]
1924        workspace_storage: PodmanWorkspaceLocator,
1925        /// The session that owns the container when this locator is a
1926        /// sub-agent child borrowing its parent's container; `None` when the
1927        /// session owns the container itself.
1928        #[serde(default, skip_serializing_if = "Option::is_none")]
1929        borrowed_from: Option<String>,
1930    },
1931    SshDocker {
1932        ssh: SshTarget,
1933        container_id: String,
1934        /// The session that owns the container when this locator is a
1935        /// sub-agent child borrowing its parent's container; `None` when the
1936        /// session owns the container itself.
1937        #[serde(default, skip_serializing_if = "Option::is_none")]
1938        borrowed_from: Option<String>,
1939    },
1940}
1941
1942impl TargetTemplate {
1943    pub const fn container_engine(&self) -> Option<&'static str> {
1944        match self {
1945            Self::LocalPodman(_) | Self::SshPodman { .. } => Some("podman"),
1946            Self::LocalDocker(_) | Self::SshDocker { .. } => Some("docker"),
1947            Self::AppleContainer(_) => Some("container"),
1948            _ => None,
1949        }
1950    }
1951
1952    /// The host that downloads this target's image, with the container
1953    /// settings that name the image. `None` for targets that run no image.
1954    pub fn image_host(&self) -> Option<(ImageHost, &ContainerTemplate)> {
1955        match self {
1956            Self::LocalPodman(container) => Some((ImageHost::LocalPodman, container)),
1957            Self::LocalDocker(container) => Some((ImageHost::LocalDocker, container)),
1958            Self::AppleContainer(container) => Some((ImageHost::AppleContainer, container)),
1959            Self::SshPodman { ssh, container } => {
1960                Some((ImageHost::SshPodman(ssh.clone()), container))
1961            }
1962            Self::SshDocker { ssh, container } => {
1963                Some((ImageHost::SshDocker(ssh.clone()), container))
1964            }
1965            Self::LocalBare | Self::AwsEc2(_) | Self::SshBare { .. } => None,
1966        }
1967    }
1968}
1969
1970impl TargetLocator {
1971    /// The target kind spelling shared with [`crate::config::TargetTemplate`].
1972    pub const fn kind_name(&self) -> &'static str {
1973        match self {
1974            Self::LocalBare { .. } => "local-bare",
1975            Self::LocalPodman { .. } => "local-podman",
1976            Self::LocalDocker { .. } => "local-docker",
1977            Self::AppleContainer { .. } => "apple-container",
1978            Self::AwsEc2 { .. } => "aws-ec2",
1979            Self::SshBare { .. } => "ssh-bare",
1980            Self::SshPodman { .. } => "ssh-podman",
1981            Self::SshDocker { .. } => "ssh-docker",
1982        }
1983    }
1984
1985    pub const fn container_engine(&self) -> Option<&'static str> {
1986        match self {
1987            Self::LocalPodman { .. } | Self::SshPodman { .. } => Some("podman"),
1988            Self::LocalDocker { .. } | Self::SshDocker { .. } => Some("docker"),
1989            Self::AppleContainer { .. } => Some("container"),
1990            _ => None,
1991        }
1992    }
1993}
1994
1995/// Commands and identity needed to bring a stopped managed target back online.
1996/// Only runtimes whose stopped resources retain their durable files provide
1997/// one; callers leave every other target kind alone.
1998#[derive(Debug, Clone, PartialEq, Eq)]
1999pub struct TargetRecoveryPlan {
2000    pub exists: CommandSpec,
2001    pub inspect: CommandSpec,
2002    pub start: CommandSpec,
2003    pub session_id: String,
2004}
2005
2006#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2007pub enum TargetRecoveryOutcome {
2008    NotRequired,
2009    Missing,
2010    AlreadyRunning,
2011    Started,
2012}
2013
2014pub fn resource_name(session_id: &str) -> Result<String> {
2015    validate_session_id(session_id)?;
2016    let readable: String = session_id
2017        .chars()
2018        .filter(|character| character.is_ascii_alphanumeric())
2019        .take(12)
2020        .map(|character| character.to_ascii_lowercase())
2021        .collect();
2022    let digest = Sha256::digest(session_id.as_bytes());
2023    Ok(format!(
2024        "mj-{readable}-{:02x}{:02x}{:02x}",
2025        digest[0], digest[1], digest[2]
2026    ))
2027}
2028
2029/// Resource generations let a Move retain its source while provisioning the destination.
2030pub fn move_resource_name(session_id: &str, operation_id: &str) -> Result<String> {
2031    let digest = Sha256::digest(operation_id.as_bytes());
2032    Ok(format!(
2033        "{}-move-{}",
2034        resource_name(session_id)?,
2035        crate::hex::lower_hex(&digest[..8])
2036    ))
2037}
2038
2039pub fn resource_name_belongs_to(name: &str, session_id: &str) -> Result<bool> {
2040    let base = resource_name(session_id)?;
2041    Ok(name == base
2042        || name
2043            .strip_prefix(&format!("{base}-move-"))
2044            .is_some_and(|suffix| {
2045                suffix.len() == 16 && suffix.bytes().all(|b| b.is_ascii_hexdigit())
2046            }))
2047}
2048
2049pub fn podman_workspace_locator(
2050    template: &ContainerTemplate,
2051    session_id: &str,
2052) -> Result<PodmanWorkspaceLocator> {
2053    podman_workspace_locator_named(template, &resource_name(session_id)?)
2054}
2055
2056pub fn podman_workspace_locator_named(
2057    template: &ContainerTemplate,
2058    name: &str,
2059) -> Result<PodmanWorkspaceLocator> {
2060    let resource = format!("{name}-workspace");
2061    match &template.workspace_storage {
2062        PodmanWorkspaceStorage::PodmanVolume => {
2063            Ok(PodmanWorkspaceLocator::Volume { name: resource })
2064        }
2065        PodmanWorkspaceStorage::HostHelper { root, helper } => {
2066            let root = Path::new(root);
2067            ensure!(
2068                root.is_absolute(),
2069                "Podman workspace storage root must be absolute"
2070            );
2071            ensure!(
2072                !helper.is_empty() && helper.iter().all(|argument| !argument.is_empty()),
2073                "Podman workspace storage helper must contain non-empty arguments"
2074            );
2075            Ok(PodmanWorkspaceLocator::HostPath {
2076                path: root.join(&resource).to_string_lossy().into_owned(),
2077                helper: helper.clone(),
2078                resource,
2079            })
2080        }
2081        PodmanWorkspaceStorage::ContainerLayer => Ok(PodmanWorkspaceLocator::ContainerLayer),
2082    }
2083}
2084
2085/// The in-container workspace root a session's repositories live under.
2086///
2087/// `recorded` is the session record's `container_workspace`. A session whose
2088/// container predates per-session workspaces has none and keeps the legacy
2089/// shared `/workspace`; every session created since records
2090/// `/workspace/<session id>`, so a build cache shared by every container on a
2091/// host never sees two checkouts of one project at the same absolute path.
2092pub fn container_workspace_root(recorded: Option<&Path>) -> String {
2093    recorded.map_or_else(
2094        || CONTAINER_WORKSPACE.to_owned(),
2095        |path| path.to_string_lossy().into_owned(),
2096    )
2097}
2098
2099/// The per-session container workspace recorded for a session created now.
2100pub fn new_container_workspace(session_id: &str) -> Result<PathBuf> {
2101    validate_session_id(session_id)?;
2102    Ok(Path::new(CONTAINER_WORKSPACE).join(session_id))
2103}
2104
2105/// The workspace directory an EC2 session owns in its login home.
2106///
2107/// EC2 instances are provisioned per session, so the path is decided by the
2108/// session id alone and is the same whether it is read from a target template
2109/// or rebuilt from a stored locator.
2110pub fn aws_workspace(session_id: &str) -> String {
2111    format!(".local/share/hel/workspaces/{session_id}")
2112}
2113
2114pub fn workspace_for(template: &TargetTemplate, session_id: &str) -> Result<String> {
2115    validate_session_id(session_id)?;
2116    match template {
2117        TargetTemplate::LocalBare => bail!("local bare projects use their selected directory"),
2118        // A container workspace is per session and recorded on the session
2119        // record, so it is read with `container_workspace_root` instead.
2120        TargetTemplate::LocalPodman(_)
2121        | TargetTemplate::LocalDocker(_)
2122        | TargetTemplate::AppleContainer(_)
2123        | TargetTemplate::SshPodman { .. }
2124        | TargetTemplate::SshDocker { .. } => {
2125            bail!("container targets use the session's recorded container workspace")
2126        }
2127        TargetTemplate::AwsEc2(_) => Ok(aws_workspace(session_id)),
2128        TargetTemplate::SshBare {
2129            workspace_prefix, ..
2130        } => {
2131            validate_workspace_prefix(workspace_prefix)?;
2132            // Interpret a leading "~/" as home-relative. Remote commands are
2133            // single-quoted, so a literal tilde would name a directory called
2134            // "~"; a relative path resolves against the login home for ssh
2135            // and scp alike.
2136            let prefix = workspace_prefix
2137                .strip_prefix("~/")
2138                .unwrap_or(workspace_prefix);
2139            Ok(format!("{}/{session_id}", prefix.trim_end_matches('/')))
2140        }
2141    }
2142}
2143
2144/// Wrap an argv vector for execution at a provisioned session target.
2145pub fn command_on_locator(
2146    locator: &TargetLocator,
2147    session_id: &str,
2148    args: Vec<String>,
2149    purpose: impl Into<String>,
2150) -> Result<CommandSpec> {
2151    verify_locator(locator, session_id)?;
2152    if args.is_empty() {
2153        bail!("target command must not be empty");
2154    }
2155    Ok(locator_command(locator, args).purpose(purpose))
2156}
2157
2158/// Wrap a non-empty argv vector for execution at a target, without checking
2159/// that the locator belongs to a particular session. Every per-target command
2160/// is built here, so each target kind is spelled out once.
2161pub fn locator_command(locator: &TargetLocator, args: Vec<String>) -> CommandSpec {
2162    match locator {
2163        TargetLocator::LocalBare { .. } => {
2164            let mut args = args.into_iter();
2165            let program = args.next().expect("target command must not be empty");
2166            CommandSpec::new(program, args)
2167        }
2168        TargetLocator::LocalPodman { container_id, .. }
2169        | TargetLocator::LocalDocker { container_id, .. }
2170        | TargetLocator::AppleContainer { container_id, .. } => container_exec(
2171            locator.container_engine().expect("local container"),
2172            container_id,
2173            args,
2174        ),
2175        TargetLocator::AwsEc2 { ssh, .. } | TargetLocator::SshBare { ssh, .. } => {
2176            ssh_command_owned(ssh, args)
2177        }
2178        TargetLocator::SshPodman {
2179            ssh, container_id, ..
2180        }
2181        | TargetLocator::SshDocker {
2182            ssh, container_id, ..
2183        } => {
2184            let mut remote = vec![
2185                locator
2186                    .container_engine()
2187                    .expect("remote container")
2188                    .to_owned(),
2189                "exec".to_owned(),
2190                "-i".to_owned(),
2191                container_id.to_owned(),
2192            ];
2193            remote.extend(args);
2194            ssh_command_owned(ssh, remote)
2195        }
2196    }
2197}
2198pub fn worker_root(locator: &TargetLocator, session_id: &str) -> Result<String> {
2199    verify_locator(locator, session_id)?;
2200    Ok(match locator {
2201        TargetLocator::LocalBare { worker_root } => worker_root.clone(),
2202        TargetLocator::LocalPodman { .. }
2203        | TargetLocator::LocalDocker { .. }
2204        | TargetLocator::AppleContainer { .. }
2205        | TargetLocator::SshPodman { .. }
2206        | TargetLocator::SshDocker { .. } => format!("/var/lib/hel/workers/{session_id}"),
2207        TargetLocator::AwsEc2 { .. } => format!(".local/share/hel/workers/{session_id}"),
2208        TargetLocator::SshBare { worker_id, .. } => format!(
2209            ".local/share/hel/workers/{}",
2210            worker_id.as_deref().unwrap_or(session_id)
2211        ),
2212    })
2213}
2214mod convert;
2215pub use convert::{
2216    RecordedTarget, StoredTarget, TargetConversionError, locator_needs_connection,
2217    ssh_args_with_identity,
2218};
2219
2220mod ssh;
2221pub use ssh::*;
2222
2223pub fn container_exec(
2224    engine: &str,
2225    container_id: &str,
2226    args: impl IntoIterator<Item = impl Into<String>>,
2227) -> CommandSpec {
2228    let mut command_args = vec!["exec".to_owned(), "-i".to_owned(), container_id.to_owned()];
2229    command_args.extend(args.into_iter().map(Into::into));
2230    CommandSpec::new(engine, command_args)
2231}
2232
2233#[cfg(all(test, unix))]
2234mod executor_tests {
2235    use std::fs;
2236
2237    use super::*;
2238
2239    #[cfg(target_os = "linux")]
2240    #[test]
2241    fn successful_commands_stop_descendants_that_closed_their_pipes() {
2242        for streamed in [false, true] {
2243            let temp = tempfile::tempdir().unwrap();
2244            let pid_file = temp.path().join("descendant");
2245            let command = CommandSpec::new("sh", [
2246                "-c".to_owned(),
2247                "cat >/dev/null; head -c 131072 /dev/zero; sleep 60 </dev/null >/dev/null 2>&1 & echo $! > \"$1\"".to_owned(),
2248                "owned-descendant".to_owned(), pid_file.display().to_string(),
2249            ]);
2250            let executor = CancellableProcessExecutor::with_timeout(Duration::from_secs(5));
2251            let output = if streamed {
2252                executor
2253                    .execute_with_stdin(&command, &mut std::io::Cursor::new(vec![b'x'; 256 * 1024]))
2254            } else {
2255                executor.execute(&command)
2256            }
2257            .unwrap();
2258            assert_eq!(output.stdout.len(), 131072);
2259            let pid: i32 = fs::read_to_string(pid_file)
2260                .unwrap()
2261                .trim()
2262                .parse()
2263                .unwrap();
2264            let deadline = Instant::now() + Duration::from_secs(2);
2265            loop {
2266                let state = fs::read_to_string(format!("/proc/{pid}/stat")).ok();
2267                if state.as_ref().is_none_or(|state| {
2268                    state
2269                        .rsplit_once(") ")
2270                        .is_some_and(|(_, fields)| fields.starts_with('Z'))
2271                }) {
2272                    break;
2273                }
2274                if Instant::now() >= deadline {
2275                    // Do not leave the fixture behind when the regression fails.
2276                    unsafe {
2277                        libc::kill(pid, libc::SIGKILL);
2278                    }
2279                    panic!(
2280                        "successful command left descendant {pid} running (streamed={streamed})"
2281                    );
2282                }
2283                std::thread::sleep(Duration::from_millis(10));
2284            }
2285        }
2286    }
2287
2288    /// B-2 at the executor: a backgrounded descendant that holds the output
2289    /// pipes must not hold the completed command past the drain.
2290    // Hard-won: c86b2123: A target command stayed Running until a background descendant closed inherited pipes.
2291    #[cfg(target_os = "linux")]
2292    #[test]
2293    fn commands_complete_at_leader_exit_when_a_descendant_holds_the_pipes() {
2294        for streamed in [false, true] {
2295            let temp = tempfile::tempdir().unwrap();
2296            let pid_file = temp.path().join("descendant");
2297            let command = CommandSpec::new(
2298                "sh",
2299                [
2300                    "-c".to_owned(),
2301                    "sleep 300 & echo $! > \"$1\"; echo hi; exit 3".to_owned(),
2302                    "held-pipes".to_owned(),
2303                    pid_file.display().to_string(),
2304                ],
2305            );
2306            let executor = CancellableProcessExecutor::with_timeout(Duration::from_secs(30));
2307            let started = Instant::now();
2308            let output = if streamed {
2309                executor.execute_with_stdin(&command, &mut std::io::Cursor::new(b"input".to_vec()))
2310            } else {
2311                executor.execute(&command)
2312            }
2313            .unwrap();
2314            assert!(
2315                started.elapsed() < Duration::from_secs(5),
2316                "streamed={streamed} took {:?}",
2317                started.elapsed()
2318            );
2319            assert_eq!(output.status, 3);
2320            assert_eq!(output.stdout, b"hi\n");
2321            let pid: i32 = fs::read_to_string(pid_file)
2322                .unwrap()
2323                .trim()
2324                .parse()
2325                .unwrap();
2326            let deadline = Instant::now() + Duration::from_secs(2);
2327            loop {
2328                let state = fs::read_to_string(format!("/proc/{pid}/stat")).ok();
2329                if state.as_ref().is_none_or(|state| {
2330                    state
2331                        .rsplit_once(") ")
2332                        .is_some_and(|(_, fields)| fields.starts_with('Z'))
2333                }) {
2334                    break;
2335                }
2336                if Instant::now() >= deadline {
2337                    // SAFETY: cleans up this test's own fixture.
2338                    unsafe {
2339                        libc::kill(pid, libc::SIGKILL);
2340                    }
2341                    panic!("completed command left descendant {pid} (streamed={streamed})");
2342                }
2343                std::thread::sleep(Duration::from_millis(10));
2344            }
2345        }
2346    }
2347
2348    #[test]
2349    fn streamed_deadline_survives_leader_exit_and_inherited_pipes() {
2350        let command = CommandSpec::new(
2351            "sh",
2352            [
2353                "-c",
2354                "head -c 131072 /dev/zero; cat >/dev/null; (trap '' TERM; sleep 60) & exit 0",
2355            ],
2356        );
2357        let mut input = std::io::Cursor::new(vec![b'x'; 256 * 1024]);
2358        let started = Instant::now();
2359        let error = CancellableProcessExecutor::with_timeout(Duration::from_millis(300))
2360            .execute_with_stdin(&command, &mut input)
2361            .unwrap_err();
2362        assert!(error.to_string().contains("cancelled"), "{error:#}");
2363        assert!(started.elapsed() < Duration::from_secs(5));
2364    }
2365
2366    /// A stand-in for `ssh` that is refused by the server on its first call and
2367    /// connects on the next, the way a host at its `MaxStartups` ceiling
2368    /// behaves once the daemon's burst drains.
2369    fn flaky_ssh_script(directory: &Path) -> CommandSpec {
2370        let counter = directory.join("attempts");
2371        let script = format!(
2372            "count=$(cat {counter} 2>/dev/null || echo 0)\n\
2373             echo $((count + 1)) > {counter}\n\
2374             if [ \"$count\" -eq 0 ]; then\n\
2375             echo 'kex_exchange_identification: Connection closed by 10.0.0.1 port 22' >&2\n\
2376             exit 255\n\
2377             fi\n\
2378             echo connected\n",
2379            counter = counter.display()
2380        );
2381        CommandSpec::new("sh", ["-c".to_owned(), script])
2382            .ssh_destination("build@10.0.0.1")
2383            .purpose("run the flaky SSH fixture")
2384    }
2385
2386    fn attempts(directory: &Path) -> u32 {
2387        fs::read_to_string(directory.join("attempts"))
2388            .expect("the fixture records its attempts")
2389            .trim()
2390            .parse()
2391            .expect("attempt count is a number")
2392    }
2393
2394    /// An executor that models `ssh` masters and the sessions on them. The
2395    /// first session finds its master dead (it was killed after the ledger
2396    /// last checked it), which `ssh` reports the way a session guarded with
2397    /// `ProxyCommand=false` does.
2398    #[derive(Default)]
2399    struct MasterKilledOnce {
2400        running: std::cell::RefCell<BTreeSet<String>>,
2401        openers: std::cell::Cell<usize>,
2402        sessions: std::cell::RefCell<Vec<Vec<String>>>,
2403    }
2404
2405    impl CommandExecutor for MasterKilledOnce {
2406        fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
2407            let reply = |status: i32, stderr: &str| CommandOutput {
2408                status,
2409                stdout: Vec::new(),
2410                stderr: stderr.as_bytes().to_vec(),
2411            };
2412            if command.ssh_session.is_some() {
2413                // A session. The first one arrives just after its master died.
2414                return with_ssh_admission(command, self, &|| false, |spawned| {
2415                    self.sessions.borrow_mut().push(spawned.args.clone());
2416                    if self.sessions.borrow().len() == 1 {
2417                        self.running.borrow_mut().clear();
2418                        return Ok(reply(255, "Connection closed by UNKNOWN port 65535"));
2419                    }
2420                    Ok(reply(0, ""))
2421                });
2422            }
2423            let socket = command
2424                .args
2425                .iter()
2426                .find_map(|arg| arg.strip_prefix("ControlPath="))
2427                .expect("a master command names its socket")
2428                .to_owned();
2429            if command.args.windows(2).any(|pair| pair == ["-O", "check"]) {
2430                return Ok(reply(
2431                    if self.running.borrow().contains(&socket) {
2432                        0
2433                    } else {
2434                        255
2435                    },
2436                    "",
2437                ));
2438            }
2439            assert!(command.args.contains(&"ControlMaster=yes".to_owned()));
2440            self.openers.set(self.openers.get() + 1);
2441            self.running.borrow_mut().insert(socket);
2442            Ok(reply(0, ""))
2443        }
2444    }
2445
2446    #[test]
2447    fn a_session_whose_master_died_is_retried_on_a_reopened_master() {
2448        let _guard = ssh::SHARING_TEST_LOCK
2449            .lock()
2450            .unwrap_or_else(std::sync::PoisonError::into_inner);
2451        set_ssh_retry_backoff_for_test(Some(Duration::from_millis(5)));
2452        let socket_dir = tempfile::tempdir_in("/tmp").expect("short socket directory");
2453        set_ssh_connection_sharing_for_test(Some(SshSharingForTest::Directory(
2454            socket_dir.path().to_path_buf(),
2455        )));
2456        let ssh = SshTarget {
2457            destination: "master-killed-once-host".to_owned(),
2458            ssh_args: Vec::new(),
2459        };
2460        let executor = MasterKilledOnce::default();
2461        let output = executor.execute(&ssh_command(&ssh, ["true"]));
2462        set_ssh_connection_sharing_for_test(None);
2463        set_ssh_retry_backoff_for_test(None);
2464
2465        assert_eq!(output.expect("the retry succeeds").status, 0);
2466        let sessions = executor.sessions.borrow();
2467        assert_eq!(sessions.len(), 2);
2468        assert_eq!(
2469            executor.openers.get(),
2470            2,
2471            "the retry reopens the master instead of trusting the earlier check"
2472        );
2473        for args in sessions.iter() {
2474            assert_eq!(args[..6][5], "ProxyCommand=false", "{args:?}");
2475        }
2476    }
2477
2478    // Hard-won: e6ed54ed: sshd MaxStartups rejected concurrent session handshakes and users saw dead workers and failed closes.
2479    #[test]
2480    fn a_transport_rejected_ssh_command_is_retried_once_and_then_succeeds() {
2481        set_ssh_retry_backoff_for_test(Some(Duration::from_millis(5)));
2482        let directory = tempfile::tempdir().expect("temp dir");
2483        let command = flaky_ssh_script(directory.path());
2484
2485        let output = ProcessExecutor
2486            .execute(&command)
2487            .expect("the retry must reach the successful attempt");
2488
2489        assert_eq!(output.status, 0);
2490        assert_eq!(String::from_utf8_lossy(&output.stdout).trim(), "connected");
2491        assert_eq!(attempts(directory.path()), 2);
2492        set_ssh_retry_backoff_for_test(None);
2493    }
2494
2495    // Hard-won: e6ed54ed: sshd MaxStartups rejected remote operations during daemon startup and they were falsely reported as failed.
2496    #[test]
2497    fn the_cancellable_executor_also_retries_a_transport_rejection() {
2498        set_ssh_retry_backoff_for_test(Some(Duration::from_millis(5)));
2499        let directory = tempfile::tempdir().expect("temp dir");
2500        let command = flaky_ssh_script(directory.path());
2501
2502        let output = CancellableProcessExecutor::new(Arc::new(AtomicBool::new(false)))
2503            .execute(&command)
2504            .expect("the retry must reach the successful attempt");
2505
2506        assert_eq!(output.status, 0);
2507        assert_eq!(attempts(directory.path()), 2);
2508        set_ssh_retry_backoff_for_test(None);
2509    }
2510}