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