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