Skip to main content

mj_core/
targets.rs

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