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