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