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