1use std::collections::{BTreeMap, BTreeSet};
8use std::fs;
9use std::io::{Read, Write};
10use std::path::{Path, PathBuf};
11use std::process::{Command, Stdio};
12use std::sync::Arc;
13use std::sync::atomic::{AtomicBool, Ordering};
14use std::time::{Duration, Instant};
15
16use anyhow::{Context, Result, bail, ensure};
17use serde::{Deserialize, Serialize};
18use sha2::{Digest, Sha256};
19
20use crate::config::{HarnessHost, HarnessKind, ImagePullPolicy};
21
22pub const SESSION_LABEL: &str = "dev.mj.session";
23pub const MANAGED_LABEL: &str = "dev.mj.managed";
24pub const SESSION_TAG: &str = "dev.mj.session";
25pub const MANAGED_TAG: &str = "dev.mj.managed";
26pub const INSTANCE_LABEL: &str = "dev.mj.instance";
29pub const INSTANCE_TAG: &str = "dev.mj.instance";
30pub const CONTAINER_WORKSPACE: &str = "/workspace";
34
35#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
37pub enum ProvisionStage {
38 PullingImage,
41 Provisioning,
42 Booting,
43 Cloning,
44 Syncing,
45 Restoring,
46 Starting,
47 Installing(HarnessKind),
48 Compacting,
49 RecoveryCopy,
50 Verifying,
51 Closing,
52 StoppingTarget,
53 RemovingContainer,
54 RemovingStorage,
55 CleaningCache,
56}
57
58impl ProvisionStage {
59 pub fn label(self) -> String {
60 match self {
61 Self::PullingImage => "Pull image".into(),
62 Self::Provisioning => "Provision".into(),
63 Self::Booting => "Boot".into(),
64 Self::Cloning => "Clone".into(),
65 Self::Syncing => "Sync".into(),
66 Self::Restoring => "Restore".into(),
67 Self::Starting => "Start".into(),
68 Self::Installing(harness) => format!("Installing {}", harness.display_name()),
69 Self::Compacting => "Compact".into(),
70 Self::RecoveryCopy => "Recovery copy".into(),
71 Self::Verifying => "Verify".into(),
72 Self::Closing => "Close".into(),
73 Self::StoppingTarget => "Stop target".into(),
74 Self::RemovingContainer => "Remove container".into(),
75 Self::RemovingStorage => "Remove container storage".into(),
76 Self::CleaningCache => "Clean cache".into(),
77 }
78 }
79}
80
81#[derive(Clone, PartialEq, Eq)]
82struct SensitiveCommandInput(Vec<u8>);
83
84impl std::fmt::Debug for SensitiveCommandInput {
85 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
86 formatter.write_str("<redacted>")
87 }
88}
89
90#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
91pub struct CommandSpec {
92 pub program: String,
93 pub args: Vec<String>,
94 #[serde(default)]
95 pub env: BTreeMap<String, String>,
96 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
98 pub clear_env: bool,
99 #[serde(default, skip_serializing_if = "Option::is_none")]
100 pub cwd: Option<std::path::PathBuf>,
101 pub purpose: String,
102 #[serde(default)]
103 pub stage: Option<ProvisionStage>,
104 #[serde(default)]
109 pub parallel_group: Option<u32>,
110 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
114 pub creates_target: bool,
115 #[serde(default, skip_serializing_if = "Option::is_none")]
120 pub ssh_destination: Option<String>,
121 #[serde(skip)]
124 sensitive_stdin: Option<SensitiveCommandInput>,
125}
126
127impl CommandSpec {
128 pub fn new(
129 program: impl Into<String>,
130 args: impl IntoIterator<Item = impl Into<String>>,
131 ) -> Self {
132 Self {
133 program: program.into(),
134 args: args.into_iter().map(Into::into).collect(),
135 env: BTreeMap::new(),
136 clear_env: false,
137 cwd: None,
138 purpose: String::new(),
139 stage: None,
140 parallel_group: None,
141 creates_target: false,
142 ssh_destination: None,
143 sensitive_stdin: None,
144 }
145 }
146
147 pub fn purpose(mut self, purpose: impl Into<String>) -> Self {
148 self.purpose = purpose.into();
149 self
150 }
151
152 pub fn stage(mut self, stage: ProvisionStage) -> Self {
153 self.stage = Some(stage);
154 self
155 }
156
157 pub fn parallel_group(mut self, group: u32) -> Self {
160 self.parallel_group = Some(group);
161 self
162 }
163
164 pub fn ssh_destination(mut self, destination: impl Into<String>) -> Self {
167 self.ssh_destination = Some(destination.into());
168 self
169 }
170
171 pub fn creates_target(mut self) -> Self {
173 self.creates_target = true;
174 self
175 }
176
177 pub fn with_sensitive_stdin(mut self, input: Vec<u8>) -> Self {
180 self.sensitive_stdin = Some(SensitiveCommandInput(input));
181 self
182 }
183}
184
185#[derive(Debug, Clone, PartialEq, Eq)]
186pub struct CommandOutput {
187 pub status: i32,
188 pub stdout: Vec<u8>,
189 pub stderr: Vec<u8>,
190}
191
192#[derive(Debug, Clone, PartialEq, Eq)]
193pub struct SessionResourceUsage {
194 pub cpu_percent: Option<u8>,
195 pub memory_current_bytes: u64,
196 pub memory_limit_bytes: Option<u64>,
197 pub swap_current_bytes: Option<u64>,
198 pub swap_limit_bytes: Option<u64>,
199 pub writable_disk_bytes: Option<u64>,
200}
201
202#[derive(Debug, Clone, PartialEq, Eq)]
203pub struct SessionResourceProbe {
204 pub memory: CommandSpec,
205 pub disk: Option<CommandSpec>,
206}
207
208#[derive(Debug, Clone, Copy, PartialEq, Eq)]
209pub enum DeploymentCapacityKind {
210 Host,
211 AwsFleet,
212}
213
214#[derive(Debug, Clone, PartialEq, Eq)]
215pub struct DeploymentCapacityTarget {
216 pub id: String,
217 pub host: String,
218 pub target_ids: Vec<String>,
219 pub kind: DeploymentCapacityKind,
220 pub local: bool,
221 pub probes: Vec<CommandSpec>,
223 pub probe_error: Option<String>,
225}
226
227#[derive(Debug, Clone, PartialEq, Eq)]
228pub struct DeploymentCapacityUsage {
229 pub cpu_percent: Option<u8>,
230 pub memory_used_bytes: u64,
231 pub memory_total_bytes: u64,
232 pub logical_cores: u64,
233 pub disk_total_bytes: Option<u64>,
234}
235
236#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
242#[serde(from = "AdditionalMountRepr", into = "AdditionalMountRepr")]
243pub struct AdditionalMount {
244 pub source: PathBuf,
245 pub destination: PathBuf,
246 pub access: MountAccess,
247}
248
249#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
251#[serde(rename_all = "snake_case")]
252pub enum MountAccess {
253 Ro,
255 Cow,
258 Rw,
260}
261
262impl MountAccess {
263 pub const ALL: [Self; 3] = [Self::Ro, Self::Cow, Self::Rw];
264
265 pub fn label(self) -> &'static str {
266 match self {
267 Self::Ro => "ro",
268 Self::Cow => "cow",
269 Self::Rw => "rw",
270 }
271 }
272
273 pub fn without_overlay(self) -> Self {
282 match self {
283 Self::Cow => Self::Ro,
284 kept => kept,
285 }
286 }
287
288 pub fn offered(overlay_available: bool) -> Vec<Self> {
291 Self::ALL
292 .into_iter()
293 .filter(|access| overlay_available || access.without_overlay() == *access)
294 .collect()
295 }
296}
297
298#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
301pub struct ImageUser {
302 pub uid: u32,
303 pub gid: u32,
304}
305
306pub fn podman_userns_option(image_user: Option<ImageUser>) -> Option<String> {
317 image_user.map(|ImageUser { uid, gid }| format!("--userns=keep-id:uid={uid},gid={gid}"))
318}
319
320#[derive(Serialize, Deserialize)]
325#[serde(deny_unknown_fields)]
326struct AdditionalMountRepr {
327 source: PathBuf,
328 destination: PathBuf,
329 #[serde(default)]
330 read_only: bool,
331 #[serde(default, skip_serializing_if = "Option::is_none")]
332 access: Option<MountAccess>,
333}
334
335impl From<AdditionalMountRepr> for AdditionalMount {
336 fn from(repr: AdditionalMountRepr) -> Self {
337 let access = repr.access.unwrap_or(if repr.read_only {
338 MountAccess::Ro
339 } else {
340 MountAccess::Cow
341 });
342 Self {
343 source: repr.source,
344 destination: repr.destination,
345 access,
346 }
347 }
348}
349
350impl From<AdditionalMount> for AdditionalMountRepr {
351 fn from(mount: AdditionalMount) -> Self {
352 Self {
353 source: mount.source,
354 destination: mount.destination,
355 read_only: mount.access == MountAccess::Ro,
356 access: (mount.access == MountAccess::Rw).then_some(MountAccess::Rw),
357 }
358 }
359}
360
361pub fn overlay_unsupported_filesystem(filesystem: &str) -> Option<&'static str> {
367 let name = filesystem.trim().to_ascii_lowercase();
368 if name == "fuse" || name == "fuseblk" || name.starts_with("fuse.") {
370 return Some("FUSE filesystem");
371 }
372 match name.as_str() {
373 "nfs" | "nfs4" | "cifs" | "smb2" | "smb3" | "9p" | "v9fs" | "virtiofs" | "ceph"
374 | "lustre" | "afs" | "glusterfs" | "ocfs2" | "gfs" | "gfs2" => Some("network filesystem"),
375 "msdos" | "vfat" | "fat" | "exfat" | "ntfs" | "ntfs3" => Some("no POSIX metadata"),
376 "overlayfs" => Some("overlay stacking limit"),
377 _ => None,
378 }
379}
380
381pub fn validate_mount_destination(path: &Path) -> Result<()> {
383 ensure!(
384 path.is_absolute()
385 && !path
386 .components()
387 .any(|part| part == std::path::Component::ParentDir),
388 "additional mount destination must be a safe absolute container path; ~ is not supported"
389 );
390 Ok(())
391}
392
393pub fn validate_additional_mounts(mounts: &[AdditionalMount]) -> Result<()> {
394 let mut destinations = BTreeSet::new();
395 for mount in mounts {
396 if !mount.source.is_absolute() || mount.source.as_os_str().is_empty() {
397 bail!("additional mount source must be an absolute directory path");
398 }
399 validate_mount_destination(&mount.destination)?;
400 if !destinations.insert(mount.destination.clone()) {
401 bail!(
402 "additional mount destination {:?} is configured more than once",
403 mount.destination
404 );
405 }
406 }
407 Ok(())
408}
409
410pub fn default_mount_destination(source: &Path, existing: &[AdditionalMount]) -> PathBuf {
412 let basename = source
413 .file_name()
414 .filter(|name| !name.is_empty())
415 .unwrap_or_else(|| std::ffi::OsStr::new("mount"));
416 let base = PathBuf::from("/mnt").join(basename);
417 if !existing.iter().any(|mount| mount.destination == base) {
418 return base;
419 }
420 for number in 2.. {
421 let candidate =
422 PathBuf::from("/mnt").join(format!("{}-{number}", basename.to_string_lossy()));
423 if !existing.iter().any(|mount| mount.destination == candidate) {
424 return candidate;
425 }
426 }
427 unreachable!("a finite mount list always has an unused numbered destination")
428}
429
430pub fn local_directory_completions(prefix: &str) -> Vec<String> {
432 let (directory, fragment) = match prefix.rsplit_once('/') {
433 Some((directory, fragment)) => (format!("{directory}/"), fragment),
434 None => (String::new(), prefix),
435 };
436 let lookup = if directory.is_empty() {
437 "."
438 } else {
439 &directory
440 };
441 let entries = match fs::read_dir(lookup) {
442 Ok(entries) => entries,
443 Err(error) => {
444 tracing::debug!(path = lookup, %error, "path completion directory could not be read");
445 return Vec::new();
446 }
447 };
448 let mut matches = entries
449 .filter_map(|entry| {
450 let entry = match entry {
451 Ok(entry) => entry,
452 Err(error) => {
453 tracing::debug!(path = lookup, %error, "path completion directory entry could not be read");
454 return None;
455 }
456 };
457 let name = entry.file_name();
458 let name = match name.to_str() {
459 Some(name) => name,
460 None => {
461 tracing::debug!(path = %entry.path().display(), "path completion skipped a non-UTF-8 directory entry");
462 return None;
463 }
464 };
465 (name.starts_with(fragment) && entry.path().is_dir())
466 .then(|| format!("{directory}{name}/"))
467 })
468 .collect::<Vec<_>>();
469 matches.sort();
470 matches.dedup();
471 matches
472}
473
474pub fn path_completion(prefix: &str, candidates: &[String]) -> Option<String> {
476 let first = candidates.first()?;
477 if candidates.len() == 1 {
478 return Some(first.clone());
479 }
480 let common = candidates
481 .iter()
482 .skip(1)
483 .fold(first.clone(), |common, next| {
484 common
485 .chars()
486 .zip(next.chars())
487 .take_while(|(left, right)| left == right)
488 .map(|(character, _)| character)
489 .collect()
490 });
491 (common.len() > prefix.len() && common.starts_with(prefix)).then_some(common)
492}
493
494pub trait CommandExecutor {
495 fn execute(&self, command: &CommandSpec) -> Result<CommandOutput>;
496
497 fn cancellation_requested(&self) -> bool {
501 false
502 }
503
504 fn stage_started(&self, _stage: ProvisionStage) {}
508
509 fn stage_finished(&self, _stage: ProvisionStage) {}
512
513 fn notify_notice(&self, _notice: &str) {}
516
517 fn execute_with_stdin(
518 &self,
519 _command: &CommandSpec,
520 _input: &mut (dyn Read + Send),
521 ) -> Result<CommandOutput> {
522 bail!("this command executor does not support streamed stdin")
523 }
524}
525
526pub struct ProvisionStageGuard<'a, E: CommandExecutor + ?Sized> {
530 executor: &'a E,
531 stage: ProvisionStage,
532}
533
534impl<'a, E: CommandExecutor + ?Sized> ProvisionStageGuard<'a, E> {
535 pub fn new(executor: &'a E, stage: ProvisionStage) -> Self {
536 executor.stage_started(stage);
537 Self { executor, stage }
538 }
539}
540
541impl<E: CommandExecutor + ?Sized> Drop for ProvisionStageGuard<'_, E> {
542 fn drop(&mut self) {
543 self.executor.stage_finished(self.stage);
544 }
545}
546
547pub struct ProcessExecutor;
548
549fn with_ssh_admission(
559 command: &CommandSpec,
560 is_cancelled: &dyn Fn() -> bool,
561 mut run: impl FnMut() -> Result<CommandOutput>,
562) -> Result<CommandOutput> {
563 let Some(destination) = command.ssh_destination.as_deref() else {
564 return run();
565 };
566 for attempt in 1..=SSH_RETRY_ATTEMPTS {
567 let output = {
568 let _permit = SshAdmission::acquire(destination);
569 run()?
570 };
571 if attempt == SSH_RETRY_ATTEMPTS
572 || !is_transport_rejection(output.status, &String::from_utf8_lossy(&output.stderr))
573 {
574 return Ok(output);
575 }
576 let delay = ssh_retry_delay(attempt);
577 tracing::warn!(
578 destination,
579 purpose = command.purpose.as_str(),
580 attempt,
581 attempts = SSH_RETRY_ATTEMPTS,
582 delay_ms = delay.as_millis() as u64,
583 stderr = String::from_utf8_lossy(&output.stderr).trim(),
584 "ssh was refused by the server before authentication; retrying"
585 );
586 if !sleep_unless_cancelled(delay, is_cancelled) {
587 bail!("operation cancelled while {}", command.purpose);
588 }
589 }
590 unreachable!("the final attempt always returns");
591}
592
593fn sleep_unless_cancelled(delay: Duration, is_cancelled: &dyn Fn() -> bool) -> bool {
596 let deadline = Instant::now() + delay;
597 loop {
598 if is_cancelled() {
599 return false;
600 }
601 let remaining = deadline.saturating_duration_since(Instant::now());
602 if remaining.is_zero() {
603 return true;
604 }
605 std::thread::sleep(remaining.min(Duration::from_millis(50)));
606 }
607}
608
609pub fn trace_command_duration(command: &CommandSpec, started: Instant, status: i32) {
612 tracing::debug!(
613 purpose = command.purpose.as_str(),
614 program = command.program.as_str(),
615 status,
616 elapsed_ms = started.elapsed().as_millis() as u64,
617 "target command finished"
618 );
619}
620
621impl ProcessExecutor {
622 fn run_once(&self, command: &CommandSpec) -> Result<CommandOutput> {
624 if let Some(input) = &command.sensitive_stdin {
625 let mut input = std::io::Cursor::new(input.0.as_slice());
626 return stream_command_with_stdin(
628 configured_command(command),
629 command,
630 &mut input,
631 &|| false,
632 );
633 }
634 let started = Instant::now();
635 let output = configured_command(command)
636 .stdin(Stdio::null())
637 .output()
638 .with_context(|| format!("run {} for {}", command.program, command.purpose))?;
639 let status = output.status.code().unwrap_or(-1);
640 trace_command_duration(command, started, status);
641 Ok(CommandOutput {
642 status,
643 stdout: output.stdout,
644 stderr: output.stderr,
645 })
646 }
647}
648
649impl CommandExecutor for ProcessExecutor {
650 fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
651 with_ssh_admission(command, &|| false, || self.run_once(command))
652 }
653
654 fn execute_with_stdin(
655 &self,
656 command: &CommandSpec,
657 input: &mut (dyn Read + Send),
658 ) -> Result<CommandOutput> {
659 let _permit = command
662 .ssh_destination
663 .as_deref()
664 .map(SshAdmission::acquire);
665 let process = configured_command(command);
666 stream_command_with_stdin(process, command, input, &|| false)
669 }
670}
671
672fn stream_command_with_stdin(
681 mut process: Command,
682 command: &CommandSpec,
683 input: &mut (dyn Read + Send),
684 is_cancelled: &(dyn Fn() -> bool + Sync),
685) -> Result<CommandOutput> {
686 let started = Instant::now();
687 if is_cancelled() {
688 bail!("operation cancelled");
689 }
690 let mut child = process
691 .stdin(Stdio::piped())
692 .stdout(Stdio::piped())
693 .stderr(Stdio::piped())
694 .spawn()
695 .with_context(|| format!("run {} for {}", command.program, command.purpose))?;
696 let stdin = child
697 .stdin
698 .take()
699 .context("streamed command stdin missing")?;
700 let mut stdout = child
701 .stdout
702 .take()
703 .context("streamed command stdout missing")?;
704 let mut stderr = child
705 .stderr
706 .take()
707 .context("streamed command stderr missing")?;
708 let stdout_reader = std::thread::spawn(move || {
711 let mut bytes = Vec::new();
712 std::io::copy(&mut stdout, &mut bytes).map(|_| bytes)
713 });
714 let stderr_reader = std::thread::spawn(move || {
715 let mut bytes = Vec::new();
716 std::io::copy(&mut stderr, &mut bytes).map(|_| bytes)
717 });
718 let process_result = std::thread::scope(|scope| -> Result<_> {
719 let input_writer = scope.spawn(move || -> Result<()> {
723 let mut stdin = stdin;
728 let mut buffer = [0_u8; 64 * 1024];
729 loop {
730 if is_cancelled() {
734 bail!("operation cancelled");
735 }
736 let count = input.read(&mut buffer).context("read command input")?;
737 if count == 0 {
738 break;
739 }
740 stdin
741 .write_all(&buffer[..count])
742 .context("stream command input")?;
743 }
744 stdin.flush().context("flush command input")
745 });
746 let status = loop {
747 if is_cancelled() {
748 terminate_cancellable_child(&mut child);
749 if let Err(error) = input_writer.join() {
750 tracing::warn!(
751 purpose = command.purpose.as_str(),
752 "streamed command input writer panicked while cancelling: {error:?}"
753 );
754 }
755 bail!("operation cancelled while {}", command.purpose);
756 }
757 match child.try_wait() {
758 Ok(Some(status)) => break status,
759 Ok(None) => std::thread::sleep(Duration::from_millis(25)),
760 Err(error) => {
761 terminate_cancellable_child(&mut child);
762 if let Err(join_error) = input_writer.join() {
763 tracing::warn!(
764 purpose = command.purpose.as_str(),
765 "streamed command input writer panicked while waiting: {join_error:?}"
766 );
767 }
768 return Err(error).with_context(|| format!("wait for {}", command.purpose));
769 }
770 }
771 };
772 let input_result = input_writer
773 .join()
774 .map_err(|_| anyhow::anyhow!("streamed command input writer panicked"))?;
775 Ok((status, input_result))
776 });
777 let stdout = stdout_reader
778 .join()
779 .map_err(|_| anyhow::anyhow!("streamed command stdout reader panicked"))??;
780 let stderr = stderr_reader
781 .join()
782 .map_err(|_| anyhow::anyhow!("streamed command stderr reader panicked"))??;
783 let (status, input_result) = process_result?;
784 if status.success() {
785 input_result?;
789 }
790 let status = status.code().unwrap_or(-1);
791 trace_command_duration(command, started, status);
792 Ok(CommandOutput {
793 status,
794 stdout,
795 stderr,
796 })
797}
798
799#[derive(Clone)]
800pub struct CancellableProcessExecutor {
801 cancelled: Arc<AtomicBool>,
802 deadline: Option<Instant>,
803}
804
805impl CancellableProcessExecutor {
806 pub fn new(cancelled: Arc<AtomicBool>) -> Self {
807 Self {
808 cancelled,
809 deadline: None,
810 }
811 }
812
813 pub fn is_cancelled(&self) -> bool {
814 self.cancelled.load(Ordering::Acquire)
815 || self
816 .deadline
817 .is_some_and(|deadline| Instant::now() >= deadline)
818 }
819
820 pub fn with_timeout(timeout: Duration) -> Self {
821 Self {
822 cancelled: Arc::new(AtomicBool::new(false)),
823 deadline: Some(Instant::now() + timeout),
824 }
825 }
826
827 pub fn with_deadline(mut self, timeout: Duration) -> Self {
830 self.deadline = Some(Instant::now() + timeout);
831 self
832 }
833
834 fn check_cancelled(&self) -> Result<()> {
835 if self.is_cancelled() {
836 bail!("operation cancelled");
837 }
838 Ok(())
839 }
840}
841
842fn configured_command(command: &CommandSpec) -> Command {
843 let mut process = Command::new(&command.program);
844 if command.clear_env {
845 process.env_clear();
846 }
847 if let Some(cwd) = &command.cwd {
848 process.current_dir(cwd);
849 }
850 process.args(&command.args).envs(&command.env);
851 process
852}
853
854fn cancellable_command(command: &CommandSpec) -> Command {
855 #[cfg(unix)]
856 let mut process = configured_command(command);
857 #[cfg(not(unix))]
858 let process = configured_command(command);
859 #[cfg(unix)]
860 {
861 use std::os::unix::process::CommandExt as _;
862 process.process_group(0);
863 }
864 process
865}
866
867fn terminate_cancellable_child(child: &mut std::process::Child) {
868 #[cfg(unix)]
869 if let Err(error) = crate::subprocess::signal_process_group(child.id() as i32, libc::SIGKILL) {
874 tracing::warn!(pid = child.id(), %error, "could not terminate cancelled command process group");
875 }
876 #[cfg(not(unix))]
877 if let Err(error) = child.kill() {
878 tracing::warn!(pid = child.id(), %error, "could not terminate cancelled command");
879 }
880 if let Err(error) = child.wait() {
881 tracing::warn!(pid = child.id(), %error, "could not reap cancelled command");
882 }
883}
884
885impl CancellableProcessExecutor {
886 fn run_once(&self, command: &CommandSpec) -> Result<CommandOutput> {
888 if let Some(input) = &command.sensitive_stdin {
889 let mut input = std::io::Cursor::new(input.0.as_slice());
890 return stream_command_with_stdin(
892 cancellable_command(command),
893 command,
894 &mut input,
895 &|| self.is_cancelled(),
896 );
897 }
898 let started = Instant::now();
899 self.check_cancelled()?;
900 let mut child = cancellable_command(command)
901 .stdin(Stdio::null())
902 .stdout(Stdio::piped())
903 .stderr(Stdio::piped())
904 .spawn()
905 .with_context(|| format!("run {} for {}", command.program, command.purpose))?;
906 let mut stdout = child.stdout.take().context("command stdout missing")?;
907 let mut stderr = child.stderr.take().context("command stderr missing")?;
908 let stdout_reader = std::thread::spawn(move || {
909 let mut bytes = Vec::new();
910 std::io::copy(&mut stdout, &mut bytes).map(|_| bytes)
911 });
912 let stderr_reader = std::thread::spawn(move || {
913 let mut bytes = Vec::new();
914 std::io::copy(&mut stderr, &mut bytes).map(|_| bytes)
915 });
916 let mut status = None;
917 let status = loop {
918 if self.is_cancelled() {
919 terminate_cancellable_child(&mut child);
920 for (stream, reader) in [("stdout", stdout_reader), ("stderr", stderr_reader)] {
921 match reader.join() {
922 Ok(Ok(_)) => {}
923 Ok(Err(error)) => {
924 tracing::warn!(stream, %error, "cancelled command reader failed")
925 }
926 Err(_) => tracing::warn!(stream, "cancelled command reader panicked"),
927 }
928 }
929 bail!("operation cancelled while {}", command.purpose);
930 }
931 if status.is_none() {
932 status = child
933 .try_wait()
934 .with_context(|| format!("wait for {}", command.purpose))?;
935 }
936 if let Some(status) = status
939 && stdout_reader.is_finished()
940 && stderr_reader.is_finished()
941 {
942 break status;
943 }
944 std::thread::sleep(Duration::from_millis(25));
945 };
946 let stdout = stdout_reader
947 .join()
948 .map_err(|_| anyhow::anyhow!("command stdout reader panicked"))??;
949 let stderr = stderr_reader
950 .join()
951 .map_err(|_| anyhow::anyhow!("command stderr reader panicked"))??;
952 let status = status.code().unwrap_or(-1);
953 trace_command_duration(command, started, status);
954 Ok(CommandOutput {
955 status,
956 stdout,
957 stderr,
958 })
959 }
960}
961
962impl CommandExecutor for CancellableProcessExecutor {
963 fn cancellation_requested(&self) -> bool {
964 self.is_cancelled()
965 }
966
967 fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
968 with_ssh_admission(command, &|| self.is_cancelled(), || self.run_once(command))
969 }
970
971 fn execute_with_stdin(
972 &self,
973 command: &CommandSpec,
974 input: &mut (dyn Read + Send),
975 ) -> Result<CommandOutput> {
976 let _permit = command
979 .ssh_destination
980 .as_deref()
981 .map(SshAdmission::acquire);
982 stream_command_with_stdin(cancellable_command(command), command, input, &|| {
985 self.is_cancelled()
986 })
987 }
988}
989
990#[derive(Debug, Clone, PartialEq, Eq)]
994pub struct CommandTimedOut {
995 pub program: String,
996 pub purpose: String,
997 pub timeout: Duration,
998}
999
1000impl std::fmt::Display for CommandTimedOut {
1001 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1002 write!(
1003 formatter,
1004 "`{}` did not answer within {} seconds while trying to {}",
1005 self.program,
1006 self.timeout.as_secs(),
1007 self.purpose
1008 )
1009 }
1010}
1011
1012impl std::error::Error for CommandTimedOut {}
1013
1014#[derive(Debug, Clone, Copy)]
1023pub struct BoundedProcessExecutor {
1024 timeout: Duration,
1025}
1026
1027impl BoundedProcessExecutor {
1028 pub const fn new(timeout: Duration) -> Self {
1029 Self { timeout }
1030 }
1031}
1032
1033impl CommandExecutor for BoundedProcessExecutor {
1034 fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
1035 let executor = CancellableProcessExecutor::with_timeout(self.timeout);
1036 executor.execute(command).map_err(|error| {
1037 if executor.is_cancelled() {
1038 anyhow::Error::new(CommandTimedOut {
1039 program: command.program.clone(),
1040 purpose: command.purpose.clone(),
1041 timeout: self.timeout,
1042 })
1043 } else {
1044 error
1045 }
1046 })
1047 }
1048
1049 fn execute_with_stdin(
1050 &self,
1051 command: &CommandSpec,
1052 input: &mut (dyn Read + Send),
1053 ) -> Result<CommandOutput> {
1054 CancellableProcessExecutor::with_timeout(self.timeout).execute_with_stdin(command, input)
1055 }
1056}
1057
1058#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1059pub struct CommandPlan {
1060 pub description: String,
1061 pub commands: Vec<CommandSpec>,
1062}
1063
1064impl CommandPlan {
1065 pub fn provide_target_environment_secret(
1069 &mut self,
1070 target: &TargetTemplate,
1071 name: &str,
1072 value: &str,
1073 ) -> Result<()> {
1074 ensure!(
1075 !name.is_empty()
1076 && name.bytes().enumerate().all(|(index, byte)| byte == b'_'
1077 || byte.is_ascii_alphabetic()
1078 || (index > 0 && byte.is_ascii_digit())),
1079 "invalid secret environment variable name"
1080 );
1081 ensure!(
1082 !value.as_bytes().contains(&b'\n') && !value.as_bytes().contains(&b'\r'),
1083 "secret environment value cannot contain a newline"
1084 );
1085 let command = self
1086 .commands
1087 .iter_mut()
1088 .find(|command| command.creates_target)
1089 .context("provisioning plan has no target creation command")?;
1090 let read_and_export = format!("IFS= read -r {name} || exit 1; export {name};");
1091 match target {
1092 TargetTemplate::LocalPodman(_)
1093 | TargetTemplate::LocalDocker(_)
1094 | TargetTemplate::AppleContainer(_) => {
1095 let program = std::mem::replace(&mut command.program, "sh".to_owned());
1096 let args = std::mem::take(&mut command.args);
1097 command.args = vec![
1098 "-c".to_owned(),
1099 format!("{read_and_export} exec \"$@\""),
1100 "mj-secret-env".to_owned(),
1101 program,
1102 ];
1103 command.args.extend(args);
1104 }
1105 TargetTemplate::SshPodman { .. } | TargetTemplate::SshDocker { .. } => {
1106 let remote = command
1107 .args
1108 .last_mut()
1109 .context("remote container command has no SSH command argument")?;
1110 *remote = format!("{read_and_export} exec {remote}");
1111 }
1112 TargetTemplate::LocalBare
1113 | TargetTemplate::AwsEc2(_)
1114 | TargetTemplate::SshBare { .. } => {
1115 bail!("target does not support inherited container environment")
1116 }
1117 }
1118 let mut input = value.as_bytes().to_vec();
1119 input.push(b'\n');
1120 command.sensitive_stdin = Some(SensitiveCommandInput(input));
1121 Ok(())
1122 }
1123
1124 pub fn execute(&self, executor: &impl CommandExecutor) -> Result<Vec<CommandOutput>> {
1125 let mut outputs = Vec::with_capacity(self.commands.len());
1126 for command in &self.commands {
1127 let output = executor.execute(command)?;
1128 if output.status != 0 {
1129 bail!(
1130 "{} failed with status {}: {}",
1131 command.purpose,
1132 output.status,
1133 String::from_utf8_lossy(&output.stderr)
1134 );
1135 }
1136 outputs.push(output);
1137 }
1138 Ok(outputs)
1139 }
1140
1141 pub fn execute_concurrent(
1153 &self,
1154 executor: &(impl CommandExecutor + Sync),
1155 ) -> Result<Vec<CommandOutput>> {
1156 let mut outputs = Vec::with_capacity(self.commands.len());
1157 let mut index = 0;
1158 while index < self.commands.len() {
1159 let group = self.commands[index].parallel_group;
1160 let mut end = index + 1;
1161 if group.is_some() {
1162 while end < self.commands.len() && self.commands[end].parallel_group == group {
1163 end += 1;
1164 }
1165 }
1166 let batch = &self.commands[index..end];
1167 if let [command] = batch {
1168 outputs.push(checked_command_output(command, executor.execute(command)?)?);
1169 } else {
1170 let results: Vec<Result<CommandOutput>> = std::thread::scope(|scope| {
1171 let handles: Vec<_> = batch
1172 .iter()
1173 .map(|command| scope.spawn(|| executor.execute(command)))
1174 .collect();
1175 handles
1176 .into_iter()
1177 .map(|handle| match handle.join() {
1178 Ok(result) => result,
1179 Err(panic) => Err(anyhow::anyhow!(
1180 "concurrent command thread panicked: {}",
1181 command_thread_panic_message(panic.as_ref())
1182 )),
1183 })
1184 .collect()
1185 });
1186 for (command, result) in batch.iter().zip(results) {
1187 outputs.push(checked_command_output(command, result?)?);
1188 }
1189 }
1190 index = end;
1191 }
1192 Ok(outputs)
1193 }
1194
1195 pub fn split_at_target_creation(&self) -> Option<(Self, Self)> {
1203 let created = self
1204 .commands
1205 .iter()
1206 .position(|command| command.creates_target)?;
1207 let (creation, remainder) = self.commands.split_at(created + 1);
1208 Some((
1209 Self {
1210 description: self.description.clone(),
1211 commands: creation.to_vec(),
1212 },
1213 Self {
1214 description: self.description.clone(),
1215 commands: remainder.to_vec(),
1216 },
1217 ))
1218 }
1219}
1220
1221pub fn checked_command_output(
1225 command: &CommandSpec,
1226 output: CommandOutput,
1227) -> Result<CommandOutput> {
1228 if output.status != 0 {
1229 bail!(
1230 "{} failed with status {}: {}",
1231 command.purpose,
1232 output.status,
1233 String::from_utf8_lossy(&output.stderr)
1234 );
1235 }
1236 Ok(output)
1237}
1238
1239pub fn command_thread_panic_message(payload: &(dyn std::any::Any + Send)) -> String {
1241 if let Some(message) = payload.downcast_ref::<&str>() {
1242 (*message).to_owned()
1243 } else if let Some(message) = payload.downcast_ref::<String>() {
1244 message.clone()
1245 } else {
1246 "non-string panic payload".to_owned()
1247 }
1248}
1249
1250#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1251pub struct RepositorySpec {
1252 pub url: Option<String>,
1254 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1255 pub push_urls: Vec<String>,
1256 pub destination: String,
1257 pub git_ref: Option<String>,
1258 #[serde(default, skip_serializing_if = "Option::is_none")]
1261 pub reference: Option<String>,
1262}
1263
1264#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1265pub struct ProjectBundleSpec {
1266 pub primary: String,
1267 pub repositories: Vec<RepositorySpec>,
1268}
1269
1270impl ProjectBundleSpec {
1271 pub fn validate(&self) -> Result<()> {
1272 validate_relative_path(&self.primary)?;
1273 if self.repositories.is_empty() {
1274 bail!("a project bundle must contain at least one repository");
1275 }
1276 let mut destinations = std::collections::BTreeSet::new();
1277 for repository in &self.repositories {
1278 validate_relative_path(&repository.destination)?;
1279 ensure!(
1280 repository
1281 .url
1282 .as_deref()
1283 .is_some_and(|url| !url.trim().is_empty() && !url.starts_with('-')),
1284 "isolated repositories require a network Git remote; configure a remote or use a raw local session"
1285 );
1286 crate::remote_git::validate_network_url(
1287 repository.url.as_deref().expect("checked above"),
1288 )?;
1289 for push_url in &repository.push_urls {
1290 crate::remote_git::validate_network_url(push_url)?;
1291 }
1292 ensure!(
1293 repository.git_ref.is_none(),
1294 "git_ref is no longer supported; remove it to start from the remote's default branch"
1295 );
1296 if !destinations.insert(&repository.destination) {
1297 bail!(
1298 "duplicate repository destination {}",
1299 repository.destination
1300 );
1301 }
1302 }
1303 if !destinations.contains(&self.primary) {
1304 bail!("primary repository is not present in the bundle");
1305 }
1306 Ok(())
1307 }
1308}
1309
1310#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1311#[serde(tag = "kind", rename_all = "snake_case")]
1312pub enum PodmanWorkspaceStorage {
1313 PodmanVolume,
1314 HostHelper {
1315 root: String,
1316 helper: Vec<String>,
1317 },
1318 #[default]
1319 ContainerLayer,
1320}
1321
1322#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1323pub struct ContainerTemplate {
1324 pub image: String,
1325 #[serde(default)]
1326 pub pull_policy: ImagePullPolicy,
1327 #[serde(default)]
1328 pub extra_run_args: Vec<String>,
1329 #[serde(default)]
1330 pub workspace_storage: PodmanWorkspaceStorage,
1331 #[serde(default)]
1334 pub build_cache: Option<crate::config::TargetBuildCache>,
1335}
1336
1337impl ImagePullPolicy {
1338 pub fn resolve(self, image: &str) -> Self {
1341 if self != Self::Auto {
1342 return self;
1343 }
1344 if image_is_digest_pinned(image) {
1345 Self::Missing
1346 } else if image_is_remote(image) && image_uses_latest_tag(image) {
1347 Self::Newer
1348 } else {
1349 Self::Missing
1350 }
1351 }
1352
1353 pub fn at_launch(self, image: &str) -> Self {
1358 if self == Self::Auto {
1359 Self::Missing
1360 } else {
1361 self.resolve(image)
1362 }
1363 }
1364
1365 pub fn describe(self, image: &str) -> &'static str {
1370 match self {
1371 Self::Always => "Pull every launch",
1372 Self::Newer => "Pull when the registry is newer",
1373 Self::Missing => "Pull only if missing",
1374 Self::Never => "Never pull",
1375 Self::Auto => match self.resolve(image) {
1376 Self::Newer => "Pull if missing at launch; refresh :latest in background",
1377 _ => "Pull if missing",
1378 },
1379 }
1380 }
1381
1382 pub fn podman_value(self) -> &'static str {
1384 match self {
1385 Self::Always => "always",
1386 Self::Newer => "newer",
1387 Self::Missing => "missing",
1388 Self::Never => "never",
1389 Self::Auto => unreachable!("auto pull policy must resolve"),
1390 }
1391 }
1392}
1393
1394#[derive(Debug, Clone, PartialEq, Eq)]
1400pub enum ImageHost {
1401 LocalPodman,
1402 LocalDocker,
1403 AppleContainer,
1404 SshPodman(SshTarget),
1405 SshDocker(SshTarget),
1406}
1407
1408impl ImageHost {
1409 pub const fn engine(&self) -> &'static str {
1410 match self {
1411 Self::LocalPodman | Self::SshPodman(_) => "podman",
1412 Self::LocalDocker | Self::SshDocker(_) => "docker",
1413 Self::AppleContainer => "container",
1414 }
1415 }
1416
1417 pub fn label(&self) -> String {
1419 match self {
1420 Self::LocalPodman => "local podman".to_owned(),
1421 Self::LocalDocker => "local docker".to_owned(),
1422 Self::AppleContainer => "apple container".to_owned(),
1423 Self::SshPodman(ssh) => format!("podman on {}", ssh.destination),
1424 Self::SshDocker(ssh) => format!("docker on {}", ssh.destination),
1425 }
1426 }
1427
1428 fn command(&self, args: Vec<String>, purpose: String) -> CommandSpec {
1429 match self {
1430 Self::LocalPodman | Self::LocalDocker | Self::AppleContainer => {
1431 CommandSpec::new(args[0].clone(), args[1..].iter().cloned())
1432 }
1433 Self::SshPodman(ssh) | Self::SshDocker(ssh) => ssh_command_owned(ssh, args),
1434 }
1435 .purpose(purpose)
1436 }
1437}
1438
1439#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
1446pub enum RefreshWhen {
1447 WhenAbsent,
1449 Always,
1451}
1452
1453#[derive(Debug, Clone, PartialEq, Eq)]
1456pub struct ImageRefresh {
1457 pub host: ImageHost,
1458 pub image: String,
1459 pub platform: Option<String>,
1460 pub when: RefreshWhen,
1463 pub image_id: CommandSpec,
1466 pub pull: CommandSpec,
1467 pub prune: Option<CommandSpec>,
1470}
1471
1472pub fn image_refresh(
1479 host: ImageHost,
1480 image: &str,
1481 platform: Option<&str>,
1482 pull_policy: ImagePullPolicy,
1483) -> Option<ImageRefresh> {
1484 let when = match pull_policy.resolve(image) {
1485 ImagePullPolicy::Always | ImagePullPolicy::Newer => RefreshWhen::Always,
1486 ImagePullPolicy::Missing => RefreshWhen::WhenAbsent,
1487 ImagePullPolicy::Never => return None,
1488 ImagePullPolicy::Auto => unreachable!("auto pull policy must resolve"),
1489 };
1490 let engine = host.engine();
1491 let apple = matches!(host, ImageHost::AppleContainer);
1495 let mut image_id_args = vec![engine.to_owned(), "image".to_owned(), "inspect".to_owned()];
1496 if !apple {
1497 image_id_args.push("--format".to_owned());
1498 image_id_args.push("{{.Id}}".to_owned());
1499 }
1500 image_id_args.push(image.to_owned());
1501 let image_id = host.command(
1502 image_id_args,
1503 format!("read the cached id of container image {image}"),
1504 );
1505 let mut pull_args = vec![engine.to_owned()];
1506 if apple {
1507 pull_args.push("image".to_owned());
1508 }
1509 pull_args.push("pull".to_owned());
1510 if let Some(platform) = platform.filter(|_| !apple) {
1512 pull_args.push(format!("--platform={platform}"));
1513 }
1514 pull_args.push(image.to_owned());
1515 let pull = host.command(pull_args, format!("refresh container image {image}"));
1516 let prune = (!apple).then(|| {
1517 host.command(
1518 vec![
1519 engine.to_owned(),
1520 "image".to_owned(),
1521 "prune".to_owned(),
1522 "-f".to_owned(),
1523 ],
1524 "remove dangling container images".to_owned(),
1525 )
1526 });
1527 Some(ImageRefresh {
1528 host,
1529 image: image.to_owned(),
1530 platform: platform.map(str::to_owned),
1531 when,
1532 image_id,
1533 pull,
1534 prune,
1535 })
1536}
1537
1538fn image_is_digest_pinned(image: &str) -> bool {
1539 image
1540 .rsplit_once('@')
1541 .is_some_and(|(_, digest)| !digest.is_empty())
1542}
1543
1544fn image_is_remote(image: &str) -> bool {
1545 !image.starts_with("localhost/") && !image.starts_with("local/")
1546}
1547
1548fn image_uses_latest_tag(image: &str) -> bool {
1549 let name = image.split_once('@').map_or(image, |(name, _)| name);
1550 let final_component = name.rsplit('/').next().unwrap_or(name);
1551 !final_component.contains(':') || final_component.ends_with(":latest")
1552}
1553
1554#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1555pub struct SshTarget {
1556 pub destination: String,
1557 #[serde(default)]
1558 pub ssh_args: Vec<String>,
1559}
1560
1561#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1562pub struct AwsTemplate {
1563 pub profile: String,
1564 pub region: String,
1565 pub launch_template: String,
1566 pub launch_template_version: Option<String>,
1567 pub instance_type: Option<String>,
1568 pub ssh: SshTarget,
1569}
1570
1571#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1572#[serde(tag = "kind", rename_all = "snake_case")]
1573pub enum TargetTemplate {
1574 LocalBare,
1575 LocalPodman(ContainerTemplate),
1576 LocalDocker(ContainerTemplate),
1577 AppleContainer(ContainerTemplate),
1578 AwsEc2(AwsTemplate),
1579 SshBare {
1580 ssh: SshTarget,
1581 #[serde(default = "default_ssh_prefix")]
1582 workspace_prefix: String,
1583 },
1584 SshPodman {
1585 ssh: SshTarget,
1586 container: ContainerTemplate,
1587 },
1588 SshDocker {
1589 ssh: SshTarget,
1590 container: ContainerTemplate,
1591 },
1592}
1593
1594fn default_ssh_prefix() -> String {
1595 ".local/share/hel/workspaces".to_owned()
1596}
1597
1598#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1599#[serde(tag = "kind", rename_all = "snake_case")]
1600pub enum PodmanWorkspaceLocator {
1601 #[default]
1602 ContainerLayer,
1603 Volume {
1604 name: String,
1605 },
1606 HostPath {
1607 path: String,
1608 helper: Vec<String>,
1609 resource: String,
1610 },
1611}
1612
1613#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1614#[serde(tag = "kind", rename_all = "snake_case")]
1615pub enum TargetLocator {
1616 LocalBare {
1617 worker_root: String,
1618 },
1619 LocalPodman {
1620 container_id: String,
1621 #[serde(default)]
1622 workspace_storage: PodmanWorkspaceLocator,
1623 #[serde(default, skip_serializing_if = "Option::is_none")]
1627 borrowed_from: Option<String>,
1628 },
1629 LocalDocker {
1630 container_id: String,
1631 #[serde(default, skip_serializing_if = "Option::is_none")]
1635 borrowed_from: Option<String>,
1636 },
1637 AppleContainer {
1638 container_id: String,
1639 #[serde(default, skip_serializing_if = "Option::is_none")]
1643 borrowed_from: Option<String>,
1644 },
1645 AwsEc2 {
1646 profile: String,
1647 region: String,
1648 instance_id: String,
1649 ssh: SshTarget,
1650 workspace: String,
1651 },
1652 SshBare {
1653 ssh: SshTarget,
1654 workspace: String,
1655 #[serde(default, skip_serializing_if = "Option::is_none")]
1657 worker_id: Option<String>,
1658 },
1659 SshPodman {
1660 ssh: SshTarget,
1661 container_id: String,
1662 #[serde(default)]
1663 workspace_storage: PodmanWorkspaceLocator,
1664 #[serde(default, skip_serializing_if = "Option::is_none")]
1668 borrowed_from: Option<String>,
1669 },
1670 SshDocker {
1671 ssh: SshTarget,
1672 container_id: String,
1673 #[serde(default, skip_serializing_if = "Option::is_none")]
1677 borrowed_from: Option<String>,
1678 },
1679}
1680
1681impl TargetTemplate {
1682 pub const fn container_engine(&self) -> Option<&'static str> {
1683 match self {
1684 Self::LocalPodman(_) | Self::SshPodman { .. } => Some("podman"),
1685 Self::LocalDocker(_) | Self::SshDocker { .. } => Some("docker"),
1686 Self::AppleContainer(_) => Some("container"),
1687 _ => None,
1688 }
1689 }
1690
1691 pub fn image_host(&self) -> Option<(ImageHost, &ContainerTemplate)> {
1694 match self {
1695 Self::LocalPodman(container) => Some((ImageHost::LocalPodman, container)),
1696 Self::LocalDocker(container) => Some((ImageHost::LocalDocker, container)),
1697 Self::AppleContainer(container) => Some((ImageHost::AppleContainer, container)),
1698 Self::SshPodman { ssh, container } => {
1699 Some((ImageHost::SshPodman(ssh.clone()), container))
1700 }
1701 Self::SshDocker { ssh, container } => {
1702 Some((ImageHost::SshDocker(ssh.clone()), container))
1703 }
1704 Self::LocalBare | Self::AwsEc2(_) | Self::SshBare { .. } => None,
1705 }
1706 }
1707}
1708
1709impl TargetLocator {
1710 pub const fn harness_host(&self) -> HarnessHost {
1714 match self {
1715 Self::LocalBare { .. } => HarnessHost::current(),
1716 _ => HarnessHost::Other,
1717 }
1718 }
1719
1720 pub const fn kind_name(&self) -> &'static str {
1722 match self {
1723 Self::LocalBare { .. } => "local-bare",
1724 Self::LocalPodman { .. } => "local-podman",
1725 Self::LocalDocker { .. } => "local-docker",
1726 Self::AppleContainer { .. } => "apple-container",
1727 Self::AwsEc2 { .. } => "aws-ec2",
1728 Self::SshBare { .. } => "ssh-bare",
1729 Self::SshPodman { .. } => "ssh-podman",
1730 Self::SshDocker { .. } => "ssh-docker",
1731 }
1732 }
1733
1734 pub const fn container_engine(&self) -> Option<&'static str> {
1735 match self {
1736 Self::LocalPodman { .. } | Self::SshPodman { .. } => Some("podman"),
1737 Self::LocalDocker { .. } | Self::SshDocker { .. } => Some("docker"),
1738 Self::AppleContainer { .. } => Some("container"),
1739 _ => None,
1740 }
1741 }
1742}
1743
1744#[derive(Debug, Clone, PartialEq, Eq)]
1748pub struct TargetRecoveryPlan {
1749 pub exists: CommandSpec,
1750 pub inspect: CommandSpec,
1751 pub start: CommandSpec,
1752 pub session_id: String,
1753}
1754
1755#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1756pub enum TargetRecoveryOutcome {
1757 NotRequired,
1758 Missing,
1759 AlreadyRunning,
1760 Started,
1761}
1762
1763pub fn resource_name(session_id: &str) -> Result<String> {
1764 validate_session_id(session_id)?;
1765 let readable: String = session_id
1766 .chars()
1767 .filter(|character| character.is_ascii_alphanumeric())
1768 .take(12)
1769 .map(|character| character.to_ascii_lowercase())
1770 .collect();
1771 let digest = Sha256::digest(session_id.as_bytes());
1772 Ok(format!(
1773 "mj-{readable}-{:02x}{:02x}{:02x}",
1774 digest[0], digest[1], digest[2]
1775 ))
1776}
1777
1778pub fn podman_workspace_locator(
1779 template: &ContainerTemplate,
1780 session_id: &str,
1781) -> Result<PodmanWorkspaceLocator> {
1782 let resource = format!("{}-workspace", resource_name(session_id)?);
1783 match &template.workspace_storage {
1784 PodmanWorkspaceStorage::PodmanVolume => {
1785 Ok(PodmanWorkspaceLocator::Volume { name: resource })
1786 }
1787 PodmanWorkspaceStorage::HostHelper { root, helper } => {
1788 let root = Path::new(root);
1789 ensure!(
1790 root.is_absolute(),
1791 "Podman workspace storage root must be absolute"
1792 );
1793 ensure!(
1794 !helper.is_empty() && helper.iter().all(|argument| !argument.is_empty()),
1795 "Podman workspace storage helper must contain non-empty arguments"
1796 );
1797 Ok(PodmanWorkspaceLocator::HostPath {
1798 path: root.join(&resource).to_string_lossy().into_owned(),
1799 helper: helper.clone(),
1800 resource,
1801 })
1802 }
1803 PodmanWorkspaceStorage::ContainerLayer => Ok(PodmanWorkspaceLocator::ContainerLayer),
1804 }
1805}
1806
1807pub fn container_workspace_root(recorded: Option<&Path>) -> String {
1815 recorded.map_or_else(
1816 || CONTAINER_WORKSPACE.to_owned(),
1817 |path| path.to_string_lossy().into_owned(),
1818 )
1819}
1820
1821pub fn new_container_workspace(session_id: &str) -> Result<PathBuf> {
1823 validate_session_id(session_id)?;
1824 Ok(Path::new(CONTAINER_WORKSPACE).join(session_id))
1825}
1826
1827pub fn aws_workspace(session_id: &str) -> String {
1833 format!(".local/share/hel/workspaces/{session_id}")
1834}
1835
1836pub fn workspace_for(template: &TargetTemplate, session_id: &str) -> Result<String> {
1837 validate_session_id(session_id)?;
1838 match template {
1839 TargetTemplate::LocalBare => bail!("local bare projects use their selected directory"),
1840 TargetTemplate::LocalPodman(_)
1843 | TargetTemplate::LocalDocker(_)
1844 | TargetTemplate::AppleContainer(_)
1845 | TargetTemplate::SshPodman { .. }
1846 | TargetTemplate::SshDocker { .. } => {
1847 bail!("container targets use the session's recorded container workspace")
1848 }
1849 TargetTemplate::AwsEc2(_) => Ok(aws_workspace(session_id)),
1850 TargetTemplate::SshBare {
1851 workspace_prefix, ..
1852 } => {
1853 validate_workspace_prefix(workspace_prefix)?;
1854 let prefix = workspace_prefix
1859 .strip_prefix("~/")
1860 .unwrap_or(workspace_prefix);
1861 Ok(format!("{}/{session_id}", prefix.trim_end_matches('/')))
1862 }
1863 }
1864}
1865
1866pub fn command_on_locator(
1868 locator: &TargetLocator,
1869 session_id: &str,
1870 args: Vec<String>,
1871 purpose: impl Into<String>,
1872) -> Result<CommandSpec> {
1873 verify_locator(locator, session_id)?;
1874 if args.is_empty() {
1875 bail!("target command must not be empty");
1876 }
1877 Ok(locator_command(locator, args).purpose(purpose))
1878}
1879
1880pub fn locator_command(locator: &TargetLocator, args: Vec<String>) -> CommandSpec {
1884 match locator {
1885 TargetLocator::LocalBare { .. } => {
1886 let mut args = args.into_iter();
1887 let program = args.next().expect("target command must not be empty");
1888 CommandSpec::new(program, args)
1889 }
1890 TargetLocator::LocalPodman { container_id, .. }
1891 | TargetLocator::LocalDocker { container_id, .. }
1892 | TargetLocator::AppleContainer { container_id, .. } => container_exec(
1893 locator.container_engine().expect("local container"),
1894 container_id,
1895 args,
1896 ),
1897 TargetLocator::AwsEc2 { ssh, .. } | TargetLocator::SshBare { ssh, .. } => {
1898 ssh_command_owned(ssh, args)
1899 }
1900 TargetLocator::SshPodman {
1901 ssh, container_id, ..
1902 }
1903 | TargetLocator::SshDocker {
1904 ssh, container_id, ..
1905 } => {
1906 let mut remote = vec![
1907 locator
1908 .container_engine()
1909 .expect("remote container")
1910 .to_owned(),
1911 "exec".to_owned(),
1912 "-i".to_owned(),
1913 container_id.to_owned(),
1914 ];
1915 remote.extend(args);
1916 ssh_command_owned(ssh, remote)
1917 }
1918 }
1919}
1920pub fn worker_root(locator: &TargetLocator, session_id: &str) -> Result<String> {
1921 verify_locator(locator, session_id)?;
1922 Ok(match locator {
1923 TargetLocator::LocalBare { worker_root } => worker_root.clone(),
1924 TargetLocator::LocalPodman { .. }
1925 | TargetLocator::LocalDocker { .. }
1926 | TargetLocator::AppleContainer { .. }
1927 | TargetLocator::SshPodman { .. }
1928 | TargetLocator::SshDocker { .. } => format!("/var/lib/hel/workers/{session_id}"),
1929 TargetLocator::AwsEc2 { .. } => format!(".local/share/hel/workers/{session_id}"),
1930 TargetLocator::SshBare { worker_id, .. } => format!(
1931 ".local/share/hel/workers/{}",
1932 worker_id.as_deref().unwrap_or(session_id)
1933 ),
1934 })
1935}
1936mod convert;
1937pub use convert::{StoredTarget, TargetConversionError, ssh_args_with_identity};
1938
1939mod ssh;
1940pub use ssh::*;
1941
1942pub fn container_exec(
1943 engine: &str,
1944 container_id: &str,
1945 args: impl IntoIterator<Item = impl Into<String>>,
1946) -> CommandSpec {
1947 let mut command_args = vec!["exec".to_owned(), "-i".to_owned(), container_id.to_owned()];
1948 command_args.extend(args.into_iter().map(Into::into));
1949 CommandSpec::new(engine, command_args)
1950}
1951
1952#[cfg(all(test, unix))]
1953mod executor_tests {
1954 use super::*;
1955
1956 fn flaky_ssh_script(directory: &Path) -> CommandSpec {
1960 let counter = directory.join("attempts");
1961 let script = format!(
1962 "count=$(cat {counter} 2>/dev/null || echo 0)\n\
1963 echo $((count + 1)) > {counter}\n\
1964 if [ \"$count\" -eq 0 ]; then\n\
1965 echo 'kex_exchange_identification: Connection closed by 10.0.0.1 port 22' >&2\n\
1966 exit 255\n\
1967 fi\n\
1968 echo connected\n",
1969 counter = counter.display()
1970 );
1971 CommandSpec::new("sh", ["-c".to_owned(), script])
1972 .ssh_destination("build@10.0.0.1")
1973 .purpose("run the flaky SSH fixture")
1974 }
1975
1976 fn attempts(directory: &Path) -> u32 {
1977 fs::read_to_string(directory.join("attempts"))
1978 .expect("the fixture records its attempts")
1979 .trim()
1980 .parse()
1981 .expect("attempt count is a number")
1982 }
1983
1984 #[test]
1985 fn a_transport_rejected_ssh_command_is_retried_once_and_then_succeeds() {
1986 set_ssh_retry_backoff_for_test(Some(Duration::from_millis(5)));
1987 let directory = tempfile::tempdir().expect("temp dir");
1988 let command = flaky_ssh_script(directory.path());
1989
1990 let output = ProcessExecutor
1991 .execute(&command)
1992 .expect("the retry must reach the successful attempt");
1993
1994 assert_eq!(output.status, 0);
1995 assert_eq!(String::from_utf8_lossy(&output.stdout).trim(), "connected");
1996 assert_eq!(attempts(directory.path()), 2);
1997 set_ssh_retry_backoff_for_test(None);
1998 }
1999
2000 #[test]
2001 fn an_untagged_command_is_not_retried_after_the_same_failure() {
2002 set_ssh_retry_backoff_for_test(Some(Duration::from_millis(5)));
2003 let directory = tempfile::tempdir().expect("temp dir");
2004 let mut command = flaky_ssh_script(directory.path());
2005 command.ssh_destination = None;
2006
2007 let output = ProcessExecutor.execute(&command).expect("runs once");
2008
2009 assert_eq!(output.status, 255);
2010 assert_eq!(attempts(directory.path()), 1);
2011 set_ssh_retry_backoff_for_test(None);
2012 }
2013
2014 #[test]
2015 fn the_cancellable_executor_also_retries_a_transport_rejection() {
2016 set_ssh_retry_backoff_for_test(Some(Duration::from_millis(5)));
2017 let directory = tempfile::tempdir().expect("temp dir");
2018 let command = flaky_ssh_script(directory.path());
2019
2020 let output = CancellableProcessExecutor::new(Arc::new(AtomicBool::new(false)))
2021 .execute(&command)
2022 .expect("the retry must reach the successful attempt");
2023
2024 assert_eq!(output.status, 0);
2025 assert_eq!(attempts(directory.path()), 2);
2026 set_ssh_retry_backoff_for_test(None);
2027 }
2028}