Skip to main content

mj_core/
targets.rs

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