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