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::{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#[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 #[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 #[serde(default)]
98 pub parallel_group: Option<u32>,
99 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
103 pub creates_target: bool,
104 #[serde(default, skip_serializing_if = "Option::is_none")]
109 pub ssh_destination: Option<String>,
110 #[serde(skip)]
113 sensitive_stdin: Option<SensitiveCommandInput>,
114}
115
116impl CommandSpec {
117 pub fn new(
118 program: impl Into<String>,
119 args: impl IntoIterator<Item = impl Into<String>>,
120 ) -> Self {
121 Self {
122 program: program.into(),
123 args: args.into_iter().map(Into::into).collect(),
124 env: BTreeMap::new(),
125 clear_env: false,
126 cwd: None,
127 purpose: String::new(),
128 stage: None,
129 parallel_group: None,
130 creates_target: false,
131 ssh_destination: None,
132 sensitive_stdin: None,
133 }
134 }
135
136 pub fn purpose(mut self, purpose: impl Into<String>) -> Self {
137 self.purpose = purpose.into();
138 self
139 }
140
141 pub fn stage(mut self, stage: ProvisionStage) -> Self {
142 self.stage = Some(stage);
143 self
144 }
145
146 pub fn parallel_group(mut self, group: u32) -> Self {
149 self.parallel_group = Some(group);
150 self
151 }
152
153 pub fn ssh_destination(mut self, destination: impl Into<String>) -> Self {
156 self.ssh_destination = Some(destination.into());
157 self
158 }
159
160 pub fn creates_target(mut self) -> Self {
162 self.creates_target = true;
163 self
164 }
165
166 pub fn with_sensitive_stdin(mut self, input: Vec<u8>) -> Self {
169 self.sensitive_stdin = Some(SensitiveCommandInput(input));
170 self
171 }
172}
173
174#[derive(Debug, Clone, PartialEq, Eq)]
175pub struct CommandOutput {
176 pub status: i32,
177 pub stdout: Vec<u8>,
178 pub stderr: Vec<u8>,
179}
180
181#[derive(Debug, Clone, PartialEq, Eq)]
182pub struct SessionResourceUsage {
183 pub cpu_percent: Option<u8>,
184 pub memory_current_bytes: u64,
185 pub memory_limit_bytes: Option<u64>,
186 pub swap_current_bytes: Option<u64>,
187 pub swap_limit_bytes: Option<u64>,
188 pub writable_disk_bytes: Option<u64>,
189}
190
191#[derive(Debug, Clone, PartialEq, Eq)]
192pub struct SessionResourceProbe {
193 pub memory: CommandSpec,
194 pub disk: Option<CommandSpec>,
195}
196
197#[derive(Debug, Clone, Copy, PartialEq, Eq)]
198pub enum DeploymentCapacityKind {
199 Host,
200 AwsFleet,
201}
202
203#[derive(Debug, Clone, PartialEq, Eq)]
204pub struct DeploymentCapacityTarget {
205 pub id: String,
206 pub host: String,
207 pub target_ids: Vec<String>,
208 pub kind: DeploymentCapacityKind,
209 pub local: bool,
210 pub probes: Vec<CommandSpec>,
212 pub probe_error: Option<String>,
214}
215
216#[derive(Debug, Clone, PartialEq, Eq)]
217pub struct DeploymentCapacityUsage {
218 pub cpu_percent: Option<u8>,
219 pub memory_used_bytes: u64,
220 pub memory_total_bytes: u64,
221 pub logical_cores: u64,
222 pub disk_total_bytes: Option<u64>,
223}
224
225#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
231#[serde(deny_unknown_fields)]
232pub struct AdditionalMount {
233 pub source: PathBuf,
234 pub destination: PathBuf,
235 #[serde(default)]
239 pub read_only: bool,
240}
241
242pub fn overlay_unsupported_filesystem(filesystem: &str) -> Option<&'static str> {
248 let name = filesystem.trim().to_ascii_lowercase();
249 if name == "fuse" || name == "fuseblk" || name.starts_with("fuse.") {
251 return Some("FUSE filesystem");
252 }
253 match name.as_str() {
254 "nfs" | "nfs4" | "cifs" | "smb2" | "smb3" | "9p" | "v9fs" | "virtiofs" | "ceph"
255 | "lustre" | "afs" | "glusterfs" | "ocfs2" | "gfs" | "gfs2" => Some("network filesystem"),
256 "msdos" | "vfat" | "fat" | "exfat" | "ntfs" | "ntfs3" => Some("no POSIX metadata"),
257 "overlayfs" => Some("overlay stacking limit"),
258 _ => None,
259 }
260}
261
262pub fn validate_mount_destination(path: &Path) -> Result<()> {
264 ensure!(
265 path.is_absolute()
266 && !path
267 .components()
268 .any(|part| part == std::path::Component::ParentDir),
269 "additional mount destination must be a safe absolute container path; ~ is not supported"
270 );
271 Ok(())
272}
273
274pub fn validate_additional_mounts(mounts: &[AdditionalMount]) -> Result<()> {
275 let mut destinations = BTreeSet::new();
276 for mount in mounts {
277 if !mount.source.is_absolute() || mount.source.as_os_str().is_empty() {
278 bail!("additional mount source must be an absolute directory path");
279 }
280 validate_mount_destination(&mount.destination)?;
281 if !destinations.insert(mount.destination.clone()) {
282 bail!(
283 "additional mount destination {:?} is configured more than once",
284 mount.destination
285 );
286 }
287 }
288 Ok(())
289}
290
291pub fn default_mount_destination(source: &Path, existing: &[AdditionalMount]) -> PathBuf {
293 let basename = source
294 .file_name()
295 .filter(|name| !name.is_empty())
296 .unwrap_or_else(|| std::ffi::OsStr::new("mount"));
297 let base = PathBuf::from("/mnt").join(basename);
298 if !existing.iter().any(|mount| mount.destination == base) {
299 return base;
300 }
301 for number in 2.. {
302 let candidate =
303 PathBuf::from("/mnt").join(format!("{}-{number}", basename.to_string_lossy()));
304 if !existing.iter().any(|mount| mount.destination == candidate) {
305 return candidate;
306 }
307 }
308 unreachable!("a finite mount list always has an unused numbered destination")
309}
310
311pub fn local_directory_completions(prefix: &str) -> Vec<String> {
313 let (directory, fragment) = match prefix.rsplit_once('/') {
314 Some((directory, fragment)) => (format!("{directory}/"), fragment),
315 None => (String::new(), prefix),
316 };
317 let lookup = if directory.is_empty() {
318 "."
319 } else {
320 &directory
321 };
322 let entries = match fs::read_dir(lookup) {
323 Ok(entries) => entries,
324 Err(error) => {
325 tracing::debug!(path = lookup, %error, "path completion directory could not be read");
326 return Vec::new();
327 }
328 };
329 let mut matches = entries
330 .filter_map(|entry| {
331 let entry = match entry {
332 Ok(entry) => entry,
333 Err(error) => {
334 tracing::debug!(path = lookup, %error, "path completion directory entry could not be read");
335 return None;
336 }
337 };
338 let name = entry.file_name();
339 let name = match name.to_str() {
340 Some(name) => name,
341 None => {
342 tracing::debug!(path = %entry.path().display(), "path completion skipped a non-UTF-8 directory entry");
343 return None;
344 }
345 };
346 (name.starts_with(fragment) && entry.path().is_dir())
347 .then(|| format!("{directory}{name}/"))
348 })
349 .collect::<Vec<_>>();
350 matches.sort();
351 matches.dedup();
352 matches
353}
354
355pub fn path_completion(prefix: &str, candidates: &[String]) -> Option<String> {
357 let first = candidates.first()?;
358 if candidates.len() == 1 {
359 return Some(first.clone());
360 }
361 let common = candidates
362 .iter()
363 .skip(1)
364 .fold(first.clone(), |common, next| {
365 common
366 .chars()
367 .zip(next.chars())
368 .take_while(|(left, right)| left == right)
369 .map(|(character, _)| character)
370 .collect()
371 });
372 (common.len() > prefix.len() && common.starts_with(prefix)).then_some(common)
373}
374
375pub trait CommandExecutor {
376 fn execute(&self, command: &CommandSpec) -> Result<CommandOutput>;
377
378 fn cancellation_requested(&self) -> bool {
382 false
383 }
384
385 fn stage_started(&self, _stage: ProvisionStage) {}
389
390 fn stage_finished(&self, _stage: ProvisionStage) {}
393
394 fn notify_notice(&self, _notice: &str) {}
397
398 fn execute_with_stdin(
399 &self,
400 _command: &CommandSpec,
401 _input: &mut (dyn Read + Send),
402 ) -> Result<CommandOutput> {
403 bail!("this command executor does not support streamed stdin")
404 }
405}
406
407pub struct ProvisionStageGuard<'a, E: CommandExecutor + ?Sized> {
411 executor: &'a E,
412 stage: ProvisionStage,
413}
414
415impl<'a, E: CommandExecutor + ?Sized> ProvisionStageGuard<'a, E> {
416 pub fn new(executor: &'a E, stage: ProvisionStage) -> Self {
417 executor.stage_started(stage);
418 Self { executor, stage }
419 }
420}
421
422impl<E: CommandExecutor + ?Sized> Drop for ProvisionStageGuard<'_, E> {
423 fn drop(&mut self) {
424 self.executor.stage_finished(self.stage);
425 }
426}
427
428pub struct ProcessExecutor;
429
430fn with_ssh_admission(
440 command: &CommandSpec,
441 is_cancelled: &dyn Fn() -> bool,
442 mut run: impl FnMut() -> Result<CommandOutput>,
443) -> Result<CommandOutput> {
444 let Some(destination) = command.ssh_destination.as_deref() else {
445 return run();
446 };
447 for attempt in 1..=SSH_RETRY_ATTEMPTS {
448 let output = {
449 let _permit = SshAdmission::acquire(destination);
450 run()?
451 };
452 if attempt == SSH_RETRY_ATTEMPTS
453 || !is_transport_rejection(output.status, &String::from_utf8_lossy(&output.stderr))
454 {
455 return Ok(output);
456 }
457 let delay = ssh_retry_delay(attempt);
458 tracing::warn!(
459 destination,
460 purpose = command.purpose.as_str(),
461 attempt,
462 attempts = SSH_RETRY_ATTEMPTS,
463 delay_ms = delay.as_millis() as u64,
464 stderr = String::from_utf8_lossy(&output.stderr).trim(),
465 "ssh was refused by the server before authentication; retrying"
466 );
467 if !sleep_unless_cancelled(delay, is_cancelled) {
468 bail!("operation cancelled while {}", command.purpose);
469 }
470 }
471 unreachable!("the final attempt always returns");
472}
473
474fn sleep_unless_cancelled(delay: Duration, is_cancelled: &dyn Fn() -> bool) -> bool {
477 let deadline = Instant::now() + delay;
478 loop {
479 if is_cancelled() {
480 return false;
481 }
482 let remaining = deadline.saturating_duration_since(Instant::now());
483 if remaining.is_zero() {
484 return true;
485 }
486 std::thread::sleep(remaining.min(Duration::from_millis(50)));
487 }
488}
489
490pub fn trace_command_duration(command: &CommandSpec, started: Instant, status: i32) {
493 tracing::debug!(
494 purpose = command.purpose.as_str(),
495 program = command.program.as_str(),
496 status,
497 elapsed_ms = started.elapsed().as_millis() as u64,
498 "target command finished"
499 );
500}
501
502impl ProcessExecutor {
503 fn run_once(&self, command: &CommandSpec) -> Result<CommandOutput> {
505 if let Some(input) = &command.sensitive_stdin {
506 let mut input = std::io::Cursor::new(input.0.as_slice());
507 return stream_command_with_stdin(
509 configured_command(command),
510 command,
511 &mut input,
512 &|| false,
513 );
514 }
515 let started = Instant::now();
516 let output = configured_command(command)
517 .stdin(Stdio::null())
518 .output()
519 .with_context(|| format!("run {} for {}", command.program, command.purpose))?;
520 let status = output.status.code().unwrap_or(-1);
521 trace_command_duration(command, started, status);
522 Ok(CommandOutput {
523 status,
524 stdout: output.stdout,
525 stderr: output.stderr,
526 })
527 }
528}
529
530impl CommandExecutor for ProcessExecutor {
531 fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
532 with_ssh_admission(command, &|| false, || self.run_once(command))
533 }
534
535 fn execute_with_stdin(
536 &self,
537 command: &CommandSpec,
538 input: &mut (dyn Read + Send),
539 ) -> Result<CommandOutput> {
540 let _permit = command
543 .ssh_destination
544 .as_deref()
545 .map(SshAdmission::acquire);
546 let process = configured_command(command);
547 stream_command_with_stdin(process, command, input, &|| false)
550 }
551}
552
553fn stream_command_with_stdin(
562 mut process: Command,
563 command: &CommandSpec,
564 input: &mut (dyn Read + Send),
565 is_cancelled: &(dyn Fn() -> bool + Sync),
566) -> Result<CommandOutput> {
567 let started = Instant::now();
568 if is_cancelled() {
569 bail!("operation cancelled");
570 }
571 let mut child = process
572 .stdin(Stdio::piped())
573 .stdout(Stdio::piped())
574 .stderr(Stdio::piped())
575 .spawn()
576 .with_context(|| format!("run {} for {}", command.program, command.purpose))?;
577 let stdin = child
578 .stdin
579 .take()
580 .context("streamed command stdin missing")?;
581 let mut stdout = child
582 .stdout
583 .take()
584 .context("streamed command stdout missing")?;
585 let mut stderr = child
586 .stderr
587 .take()
588 .context("streamed command stderr missing")?;
589 let stdout_reader = std::thread::spawn(move || {
592 let mut bytes = Vec::new();
593 std::io::copy(&mut stdout, &mut bytes).map(|_| bytes)
594 });
595 let stderr_reader = std::thread::spawn(move || {
596 let mut bytes = Vec::new();
597 std::io::copy(&mut stderr, &mut bytes).map(|_| bytes)
598 });
599 let process_result = std::thread::scope(|scope| -> Result<_> {
600 let input_writer = scope.spawn(move || -> Result<()> {
604 let mut stdin = stdin;
609 let mut buffer = [0_u8; 64 * 1024];
610 loop {
611 if is_cancelled() {
615 bail!("operation cancelled");
616 }
617 let count = input.read(&mut buffer).context("read command input")?;
618 if count == 0 {
619 break;
620 }
621 stdin
622 .write_all(&buffer[..count])
623 .context("stream command input")?;
624 }
625 stdin.flush().context("flush command input")
626 });
627 let status = loop {
628 if is_cancelled() {
629 terminate_cancellable_child(&mut child);
630 if let Err(error) = input_writer.join() {
631 tracing::warn!(
632 purpose = command.purpose.as_str(),
633 "streamed command input writer panicked while cancelling: {error:?}"
634 );
635 }
636 bail!("operation cancelled while {}", command.purpose);
637 }
638 match child.try_wait() {
639 Ok(Some(status)) => break status,
640 Ok(None) => std::thread::sleep(Duration::from_millis(25)),
641 Err(error) => {
642 terminate_cancellable_child(&mut child);
643 if let Err(join_error) = input_writer.join() {
644 tracing::warn!(
645 purpose = command.purpose.as_str(),
646 "streamed command input writer panicked while waiting: {join_error:?}"
647 );
648 }
649 return Err(error).with_context(|| format!("wait for {}", command.purpose));
650 }
651 }
652 };
653 let input_result = input_writer
654 .join()
655 .map_err(|_| anyhow::anyhow!("streamed command input writer panicked"))?;
656 Ok((status, input_result))
657 });
658 let stdout = stdout_reader
659 .join()
660 .map_err(|_| anyhow::anyhow!("streamed command stdout reader panicked"))??;
661 let stderr = stderr_reader
662 .join()
663 .map_err(|_| anyhow::anyhow!("streamed command stderr reader panicked"))??;
664 let (status, input_result) = process_result?;
665 if status.success() {
666 input_result?;
670 }
671 let status = status.code().unwrap_or(-1);
672 trace_command_duration(command, started, status);
673 Ok(CommandOutput {
674 status,
675 stdout,
676 stderr,
677 })
678}
679
680#[derive(Clone)]
681pub struct CancellableProcessExecutor {
682 cancelled: Arc<AtomicBool>,
683 deadline: Option<Instant>,
684}
685
686impl CancellableProcessExecutor {
687 pub fn new(cancelled: Arc<AtomicBool>) -> Self {
688 Self {
689 cancelled,
690 deadline: None,
691 }
692 }
693
694 pub fn is_cancelled(&self) -> bool {
695 self.cancelled.load(Ordering::Acquire)
696 || self
697 .deadline
698 .is_some_and(|deadline| Instant::now() >= deadline)
699 }
700
701 pub fn with_timeout(timeout: Duration) -> Self {
702 Self {
703 cancelled: Arc::new(AtomicBool::new(false)),
704 deadline: Some(Instant::now() + timeout),
705 }
706 }
707
708 pub fn with_deadline(mut self, timeout: Duration) -> Self {
711 self.deadline = Some(Instant::now() + timeout);
712 self
713 }
714
715 fn check_cancelled(&self) -> Result<()> {
716 if self.is_cancelled() {
717 bail!("operation cancelled");
718 }
719 Ok(())
720 }
721}
722
723fn configured_command(command: &CommandSpec) -> Command {
724 let mut process = Command::new(&command.program);
725 if command.clear_env {
726 process.env_clear();
727 }
728 if let Some(cwd) = &command.cwd {
729 process.current_dir(cwd);
730 }
731 process.args(&command.args).envs(&command.env);
732 process
733}
734
735fn cancellable_command(command: &CommandSpec) -> Command {
736 #[cfg(unix)]
737 let mut process = configured_command(command);
738 #[cfg(not(unix))]
739 let process = configured_command(command);
740 #[cfg(unix)]
741 {
742 use std::os::unix::process::CommandExt as _;
743 process.process_group(0);
744 }
745 process
746}
747
748fn terminate_cancellable_child(child: &mut std::process::Child) {
749 #[cfg(unix)]
750 if let Err(error) = crate::subprocess::signal_process_group(child.id() as i32, libc::SIGKILL) {
755 tracing::warn!(pid = child.id(), %error, "could not terminate cancelled command process group");
756 }
757 #[cfg(not(unix))]
758 if let Err(error) = child.kill() {
759 tracing::warn!(pid = child.id(), %error, "could not terminate cancelled command");
760 }
761 if let Err(error) = child.wait() {
762 tracing::warn!(pid = child.id(), %error, "could not reap cancelled command");
763 }
764}
765
766impl CancellableProcessExecutor {
767 fn run_once(&self, command: &CommandSpec) -> Result<CommandOutput> {
769 if let Some(input) = &command.sensitive_stdin {
770 let mut input = std::io::Cursor::new(input.0.as_slice());
771 return stream_command_with_stdin(
773 cancellable_command(command),
774 command,
775 &mut input,
776 &|| self.is_cancelled(),
777 );
778 }
779 let started = Instant::now();
780 self.check_cancelled()?;
781 let mut child = cancellable_command(command)
782 .stdin(Stdio::null())
783 .stdout(Stdio::piped())
784 .stderr(Stdio::piped())
785 .spawn()
786 .with_context(|| format!("run {} for {}", command.program, command.purpose))?;
787 let mut stdout = child.stdout.take().context("command stdout missing")?;
788 let mut stderr = child.stderr.take().context("command stderr missing")?;
789 let stdout_reader = std::thread::spawn(move || {
790 let mut bytes = Vec::new();
791 std::io::copy(&mut stdout, &mut bytes).map(|_| bytes)
792 });
793 let stderr_reader = std::thread::spawn(move || {
794 let mut bytes = Vec::new();
795 std::io::copy(&mut stderr, &mut bytes).map(|_| bytes)
796 });
797 let mut status = None;
798 let status = loop {
799 if self.is_cancelled() {
800 terminate_cancellable_child(&mut child);
801 for (stream, reader) in [("stdout", stdout_reader), ("stderr", stderr_reader)] {
802 match reader.join() {
803 Ok(Ok(_)) => {}
804 Ok(Err(error)) => {
805 tracing::warn!(stream, %error, "cancelled command reader failed")
806 }
807 Err(_) => tracing::warn!(stream, "cancelled command reader panicked"),
808 }
809 }
810 bail!("operation cancelled while {}", command.purpose);
811 }
812 if status.is_none() {
813 status = child
814 .try_wait()
815 .with_context(|| format!("wait for {}", command.purpose))?;
816 }
817 if let Some(status) = status
820 && stdout_reader.is_finished()
821 && stderr_reader.is_finished()
822 {
823 break status;
824 }
825 std::thread::sleep(Duration::from_millis(25));
826 };
827 let stdout = stdout_reader
828 .join()
829 .map_err(|_| anyhow::anyhow!("command stdout reader panicked"))??;
830 let stderr = stderr_reader
831 .join()
832 .map_err(|_| anyhow::anyhow!("command stderr reader panicked"))??;
833 let status = status.code().unwrap_or(-1);
834 trace_command_duration(command, started, status);
835 Ok(CommandOutput {
836 status,
837 stdout,
838 stderr,
839 })
840 }
841}
842
843impl CommandExecutor for CancellableProcessExecutor {
844 fn cancellation_requested(&self) -> bool {
845 self.is_cancelled()
846 }
847
848 fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
849 with_ssh_admission(command, &|| self.is_cancelled(), || self.run_once(command))
850 }
851
852 fn execute_with_stdin(
853 &self,
854 command: &CommandSpec,
855 input: &mut (dyn Read + Send),
856 ) -> Result<CommandOutput> {
857 let _permit = command
860 .ssh_destination
861 .as_deref()
862 .map(SshAdmission::acquire);
863 stream_command_with_stdin(cancellable_command(command), command, input, &|| {
866 self.is_cancelled()
867 })
868 }
869}
870
871#[derive(Debug, Clone, Copy)]
880pub struct BoundedProcessExecutor {
881 timeout: Duration,
882}
883
884impl BoundedProcessExecutor {
885 pub const fn new(timeout: Duration) -> Self {
886 Self { timeout }
887 }
888}
889
890impl CommandExecutor for BoundedProcessExecutor {
891 fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
892 let executor = CancellableProcessExecutor::with_timeout(self.timeout);
893 executor.execute(command).map_err(|error| {
894 if executor.is_cancelled() {
895 anyhow::anyhow!(
896 "`{}` did not answer within {} seconds while trying to {}",
897 command.program,
898 self.timeout.as_secs(),
899 command.purpose
900 )
901 } else {
902 error
903 }
904 })
905 }
906
907 fn execute_with_stdin(
908 &self,
909 command: &CommandSpec,
910 input: &mut (dyn Read + Send),
911 ) -> Result<CommandOutput> {
912 CancellableProcessExecutor::with_timeout(self.timeout).execute_with_stdin(command, input)
913 }
914}
915
916#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
917pub struct CommandPlan {
918 pub description: String,
919 pub commands: Vec<CommandSpec>,
920}
921
922impl CommandPlan {
923 pub fn provide_target_environment_secret(
927 &mut self,
928 target: &TargetTemplate,
929 name: &str,
930 value: &str,
931 ) -> Result<()> {
932 ensure!(
933 !name.is_empty()
934 && name.bytes().enumerate().all(|(index, byte)| byte == b'_'
935 || byte.is_ascii_alphabetic()
936 || (index > 0 && byte.is_ascii_digit())),
937 "invalid secret environment variable name"
938 );
939 ensure!(
940 !value.as_bytes().contains(&b'\n') && !value.as_bytes().contains(&b'\r'),
941 "secret environment value cannot contain a newline"
942 );
943 let command = self
944 .commands
945 .iter_mut()
946 .find(|command| command.creates_target)
947 .context("provisioning plan has no target creation command")?;
948 let read_and_export = format!("IFS= read -r {name} || exit 1; export {name};");
949 match target {
950 TargetTemplate::LocalPodman(_)
951 | TargetTemplate::LocalDocker(_)
952 | TargetTemplate::AppleContainer(_) => {
953 let program = std::mem::replace(&mut command.program, "sh".to_owned());
954 let args = std::mem::take(&mut command.args);
955 command.args = vec![
956 "-c".to_owned(),
957 format!("{read_and_export} exec \"$@\""),
958 "mj-secret-env".to_owned(),
959 program,
960 ];
961 command.args.extend(args);
962 }
963 TargetTemplate::SshPodman { .. } | TargetTemplate::SshDocker { .. } => {
964 let remote = command
965 .args
966 .last_mut()
967 .context("remote container command has no SSH command argument")?;
968 *remote = format!("{read_and_export} exec {remote}");
969 }
970 TargetTemplate::LocalBare
971 | TargetTemplate::AwsEc2(_)
972 | TargetTemplate::SshBare { .. } => {
973 bail!("target does not support inherited container environment")
974 }
975 }
976 let mut input = value.as_bytes().to_vec();
977 input.push(b'\n');
978 command.sensitive_stdin = Some(SensitiveCommandInput(input));
979 Ok(())
980 }
981
982 pub fn execute(&self, executor: &impl CommandExecutor) -> Result<Vec<CommandOutput>> {
983 let mut outputs = Vec::with_capacity(self.commands.len());
984 for command in &self.commands {
985 let output = executor.execute(command)?;
986 if output.status != 0 {
987 bail!(
988 "{} failed with status {}: {}",
989 command.purpose,
990 output.status,
991 String::from_utf8_lossy(&output.stderr)
992 );
993 }
994 outputs.push(output);
995 }
996 Ok(outputs)
997 }
998
999 pub fn execute_concurrent(
1011 &self,
1012 executor: &(impl CommandExecutor + Sync),
1013 ) -> Result<Vec<CommandOutput>> {
1014 let mut outputs = Vec::with_capacity(self.commands.len());
1015 let mut index = 0;
1016 while index < self.commands.len() {
1017 let group = self.commands[index].parallel_group;
1018 let mut end = index + 1;
1019 if group.is_some() {
1020 while end < self.commands.len() && self.commands[end].parallel_group == group {
1021 end += 1;
1022 }
1023 }
1024 let batch = &self.commands[index..end];
1025 if let [command] = batch {
1026 outputs.push(checked_command_output(command, executor.execute(command)?)?);
1027 } else {
1028 let results: Vec<Result<CommandOutput>> = std::thread::scope(|scope| {
1029 let handles: Vec<_> = batch
1030 .iter()
1031 .map(|command| scope.spawn(|| executor.execute(command)))
1032 .collect();
1033 handles
1034 .into_iter()
1035 .map(|handle| match handle.join() {
1036 Ok(result) => result,
1037 Err(panic) => Err(anyhow::anyhow!(
1038 "concurrent command thread panicked: {}",
1039 command_thread_panic_message(panic.as_ref())
1040 )),
1041 })
1042 .collect()
1043 });
1044 for (command, result) in batch.iter().zip(results) {
1045 outputs.push(checked_command_output(command, result?)?);
1046 }
1047 }
1048 index = end;
1049 }
1050 Ok(outputs)
1051 }
1052
1053 pub fn split_at_target_creation(&self) -> Option<(Self, Self)> {
1061 let created = self
1062 .commands
1063 .iter()
1064 .position(|command| command.creates_target)?;
1065 let (creation, remainder) = self.commands.split_at(created + 1);
1066 Some((
1067 Self {
1068 description: self.description.clone(),
1069 commands: creation.to_vec(),
1070 },
1071 Self {
1072 description: self.description.clone(),
1073 commands: remainder.to_vec(),
1074 },
1075 ))
1076 }
1077}
1078
1079pub fn checked_command_output(
1083 command: &CommandSpec,
1084 output: CommandOutput,
1085) -> Result<CommandOutput> {
1086 if output.status != 0 {
1087 bail!(
1088 "{} failed with status {}: {}",
1089 command.purpose,
1090 output.status,
1091 String::from_utf8_lossy(&output.stderr)
1092 );
1093 }
1094 Ok(output)
1095}
1096
1097pub fn command_thread_panic_message(payload: &(dyn std::any::Any + Send)) -> String {
1099 if let Some(message) = payload.downcast_ref::<&str>() {
1100 (*message).to_owned()
1101 } else if let Some(message) = payload.downcast_ref::<String>() {
1102 message.clone()
1103 } else {
1104 "non-string panic payload".to_owned()
1105 }
1106}
1107
1108#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1109pub struct RepositorySpec {
1110 pub url: Option<String>,
1112 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1113 pub push_urls: Vec<String>,
1114 pub destination: String,
1115 pub git_ref: Option<String>,
1116 #[serde(default, skip_serializing_if = "Option::is_none")]
1119 pub reference: Option<String>,
1120}
1121
1122#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1123pub struct ProjectBundleSpec {
1124 pub primary: String,
1125 pub repositories: Vec<RepositorySpec>,
1126}
1127
1128impl ProjectBundleSpec {
1129 pub fn validate(&self) -> Result<()> {
1130 validate_relative_path(&self.primary)?;
1131 if self.repositories.is_empty() {
1132 bail!("a project bundle must contain at least one repository");
1133 }
1134 let mut destinations = std::collections::BTreeSet::new();
1135 for repository in &self.repositories {
1136 validate_relative_path(&repository.destination)?;
1137 ensure!(
1138 repository
1139 .url
1140 .as_deref()
1141 .is_some_and(|url| !url.trim().is_empty() && !url.starts_with('-')),
1142 "isolated repositories require a network Git remote; configure a remote or use a raw local session"
1143 );
1144 crate::remote_git::validate_network_url(
1145 repository.url.as_deref().expect("checked above"),
1146 )?;
1147 for push_url in &repository.push_urls {
1148 crate::remote_git::validate_network_url(push_url)?;
1149 }
1150 ensure!(
1151 repository.git_ref.is_none(),
1152 "git_ref is no longer supported; remove it to start from the remote's default branch"
1153 );
1154 if !destinations.insert(&repository.destination) {
1155 bail!(
1156 "duplicate repository destination {}",
1157 repository.destination
1158 );
1159 }
1160 }
1161 if !destinations.contains(&self.primary) {
1162 bail!("primary repository is not present in the bundle");
1163 }
1164 Ok(())
1165 }
1166}
1167
1168#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1169#[serde(tag = "kind", rename_all = "snake_case")]
1170pub enum PodmanWorkspaceStorage {
1171 PodmanVolume,
1172 HostHelper {
1173 root: String,
1174 helper: Vec<String>,
1175 },
1176 #[default]
1177 ContainerLayer,
1178}
1179
1180#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1181pub struct ContainerTemplate {
1182 pub image: String,
1183 #[serde(default)]
1184 pub pull_policy: ImagePullPolicy,
1185 #[serde(default)]
1186 pub extra_run_args: Vec<String>,
1187 #[serde(default)]
1188 pub workspace_storage: PodmanWorkspaceStorage,
1189}
1190
1191impl ImagePullPolicy {
1192 pub fn resolve(self, image: &str) -> Self {
1195 if self != Self::Auto {
1196 return self;
1197 }
1198 if image_is_digest_pinned(image) {
1199 Self::Missing
1200 } else if image_is_remote(image) && image_uses_latest_tag(image) {
1201 Self::Newer
1202 } else {
1203 Self::Missing
1204 }
1205 }
1206
1207 pub fn at_launch(self, image: &str) -> Self {
1212 if self == Self::Auto {
1213 Self::Missing
1214 } else {
1215 self.resolve(image)
1216 }
1217 }
1218
1219 pub fn podman_value(self) -> &'static str {
1221 match self {
1222 Self::Always => "always",
1223 Self::Newer => "newer",
1224 Self::Missing => "missing",
1225 Self::Never => "never",
1226 Self::Auto => unreachable!("auto pull policy must resolve"),
1227 }
1228 }
1229}
1230
1231#[derive(Debug, Clone, PartialEq, Eq)]
1237pub enum ImageHost {
1238 LocalPodman,
1239 LocalDocker,
1240 SshPodman(SshTarget),
1241 SshDocker(SshTarget),
1242}
1243
1244impl ImageHost {
1245 const fn engine(&self) -> &'static str {
1246 match self {
1247 Self::LocalPodman | Self::SshPodman(_) => "podman",
1248 Self::LocalDocker | Self::SshDocker(_) => "docker",
1249 }
1250 }
1251
1252 pub fn label(&self) -> String {
1254 match self {
1255 Self::LocalPodman => "local podman".to_owned(),
1256 Self::LocalDocker => "local docker".to_owned(),
1257 Self::SshPodman(ssh) => format!("podman on {}", ssh.destination),
1258 Self::SshDocker(ssh) => format!("docker on {}", ssh.destination),
1259 }
1260 }
1261
1262 fn command(&self, args: Vec<String>, purpose: String) -> CommandSpec {
1263 match self {
1264 Self::LocalPodman | Self::LocalDocker => {
1265 CommandSpec::new(args[0].clone(), args[1..].iter().cloned())
1266 }
1267 Self::SshPodman(ssh) | Self::SshDocker(ssh) => ssh_command_owned(ssh, args),
1268 }
1269 .purpose(purpose)
1270 }
1271}
1272
1273#[derive(Debug, Clone, PartialEq, Eq)]
1276pub struct ImageRefresh {
1277 pub host: ImageHost,
1278 pub image: String,
1279 pub platform: Option<String>,
1280 pub image_id: CommandSpec,
1283 pub pull: CommandSpec,
1284 pub prune: CommandSpec,
1286}
1287
1288pub fn image_refresh(
1291 host: ImageHost,
1292 image: &str,
1293 platform: Option<&str>,
1294 pull_policy: ImagePullPolicy,
1295) -> Option<ImageRefresh> {
1296 if !matches!(
1297 pull_policy.resolve(image),
1298 ImagePullPolicy::Always | ImagePullPolicy::Newer
1299 ) {
1300 return None;
1301 }
1302 let engine = host.engine();
1303 let image_id = host.command(
1304 vec![
1305 engine.to_owned(),
1306 "image".to_owned(),
1307 "inspect".to_owned(),
1308 "--format".to_owned(),
1309 "{{.Id}}".to_owned(),
1310 image.to_owned(),
1311 ],
1312 format!("read the cached id of container image {image}"),
1313 );
1314 let mut pull_args = vec![engine.to_owned(), "pull".to_owned()];
1315 if let Some(platform) = platform {
1316 pull_args.push(format!("--platform={platform}"));
1317 }
1318 pull_args.push(image.to_owned());
1319 let pull = host.command(pull_args, format!("refresh container image {image}"));
1320 let prune = host.command(
1321 vec![
1322 engine.to_owned(),
1323 "image".to_owned(),
1324 "prune".to_owned(),
1325 "-f".to_owned(),
1326 ],
1327 "remove dangling container images".to_owned(),
1328 );
1329 Some(ImageRefresh {
1330 host,
1331 image: image.to_owned(),
1332 platform: platform.map(str::to_owned),
1333 image_id,
1334 pull,
1335 prune,
1336 })
1337}
1338
1339fn image_is_digest_pinned(image: &str) -> bool {
1340 image
1341 .rsplit_once('@')
1342 .is_some_and(|(_, digest)| !digest.is_empty())
1343}
1344
1345fn image_is_remote(image: &str) -> bool {
1346 !image.starts_with("localhost/") && !image.starts_with("local/")
1347}
1348
1349fn image_uses_latest_tag(image: &str) -> bool {
1350 let name = image.split_once('@').map_or(image, |(name, _)| name);
1351 let final_component = name.rsplit('/').next().unwrap_or(name);
1352 !final_component.contains(':') || final_component.ends_with(":latest")
1353}
1354
1355#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1356pub struct SshTarget {
1357 pub destination: String,
1358 #[serde(default)]
1359 pub ssh_args: Vec<String>,
1360}
1361
1362#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1363pub struct AwsTemplate {
1364 pub profile: String,
1365 pub region: String,
1366 pub launch_template: String,
1367 pub launch_template_version: Option<String>,
1368 pub instance_type: Option<String>,
1369 pub ssh: SshTarget,
1370}
1371
1372#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1373#[serde(tag = "kind", rename_all = "snake_case")]
1374pub enum TargetTemplate {
1375 LocalBare,
1376 LocalPodman(ContainerTemplate),
1377 LocalDocker(ContainerTemplate),
1378 AppleContainer(ContainerTemplate),
1379 AwsEc2(AwsTemplate),
1380 SshBare {
1381 ssh: SshTarget,
1382 #[serde(default = "default_ssh_prefix")]
1383 workspace_prefix: String,
1384 },
1385 SshPodman {
1386 ssh: SshTarget,
1387 container: ContainerTemplate,
1388 },
1389 SshDocker {
1390 ssh: SshTarget,
1391 container: ContainerTemplate,
1392 },
1393}
1394
1395fn default_ssh_prefix() -> String {
1396 ".local/share/hel/workspaces".to_owned()
1397}
1398
1399#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1400#[serde(tag = "kind", rename_all = "snake_case")]
1401pub enum PodmanWorkspaceLocator {
1402 #[default]
1403 ContainerLayer,
1404 Volume {
1405 name: String,
1406 },
1407 HostPath {
1408 path: String,
1409 helper: Vec<String>,
1410 resource: String,
1411 },
1412}
1413
1414#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1415#[serde(tag = "kind", rename_all = "snake_case")]
1416pub enum TargetLocator {
1417 LocalBare {
1418 worker_root: String,
1419 },
1420 LocalPodman {
1421 container_id: String,
1422 #[serde(default)]
1423 workspace_storage: PodmanWorkspaceLocator,
1424 },
1425 LocalDocker {
1426 container_id: String,
1427 },
1428 AppleContainer {
1429 container_id: String,
1430 },
1431 AwsEc2 {
1432 profile: String,
1433 region: String,
1434 instance_id: String,
1435 ssh: SshTarget,
1436 workspace: String,
1437 },
1438 SshBare {
1439 ssh: SshTarget,
1440 workspace: String,
1441 #[serde(default, skip_serializing_if = "Option::is_none")]
1443 worker_id: Option<String>,
1444 },
1445 SshPodman {
1446 ssh: SshTarget,
1447 container_id: String,
1448 #[serde(default)]
1449 workspace_storage: PodmanWorkspaceLocator,
1450 },
1451 SshDocker {
1452 ssh: SshTarget,
1453 container_id: String,
1454 },
1455}
1456
1457impl TargetTemplate {
1458 pub const fn container_engine(&self) -> Option<&'static str> {
1459 match self {
1460 Self::LocalPodman(_) | Self::SshPodman { .. } => Some("podman"),
1461 Self::LocalDocker(_) | Self::SshDocker { .. } => Some("docker"),
1462 Self::AppleContainer(_) => Some("container"),
1463 _ => None,
1464 }
1465 }
1466}
1467
1468impl TargetLocator {
1469 pub const fn container_engine(&self) -> Option<&'static str> {
1470 match self {
1471 Self::LocalPodman { .. } | Self::SshPodman { .. } => Some("podman"),
1472 Self::LocalDocker { .. } | Self::SshDocker { .. } => Some("docker"),
1473 Self::AppleContainer { .. } => Some("container"),
1474 _ => None,
1475 }
1476 }
1477}
1478
1479#[derive(Debug, Clone, PartialEq, Eq)]
1483pub struct TargetRecoveryPlan {
1484 pub exists: CommandSpec,
1485 pub inspect: CommandSpec,
1486 pub start: CommandSpec,
1487 pub session_id: String,
1488}
1489
1490#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1491pub enum TargetRecoveryOutcome {
1492 NotRequired,
1493 Missing,
1494 AlreadyRunning,
1495 Started,
1496}
1497
1498pub fn resource_name(session_id: &str) -> Result<String> {
1499 validate_session_id(session_id)?;
1500 let readable: String = session_id
1501 .chars()
1502 .filter(|character| character.is_ascii_alphanumeric())
1503 .take(12)
1504 .map(|character| character.to_ascii_lowercase())
1505 .collect();
1506 let digest = Sha256::digest(session_id.as_bytes());
1507 Ok(format!(
1508 "mj-{readable}-{:02x}{:02x}{:02x}",
1509 digest[0], digest[1], digest[2]
1510 ))
1511}
1512
1513pub fn podman_workspace_locator(
1514 template: &ContainerTemplate,
1515 session_id: &str,
1516) -> Result<PodmanWorkspaceLocator> {
1517 let resource = format!("{}-workspace", resource_name(session_id)?);
1518 match &template.workspace_storage {
1519 PodmanWorkspaceStorage::PodmanVolume => {
1520 Ok(PodmanWorkspaceLocator::Volume { name: resource })
1521 }
1522 PodmanWorkspaceStorage::HostHelper { root, helper } => {
1523 let root = Path::new(root);
1524 ensure!(
1525 root.is_absolute(),
1526 "Podman workspace storage root must be absolute"
1527 );
1528 ensure!(
1529 !helper.is_empty() && helper.iter().all(|argument| !argument.is_empty()),
1530 "Podman workspace storage helper must contain non-empty arguments"
1531 );
1532 Ok(PodmanWorkspaceLocator::HostPath {
1533 path: root.join(&resource).to_string_lossy().into_owned(),
1534 helper: helper.clone(),
1535 resource,
1536 })
1537 }
1538 PodmanWorkspaceStorage::ContainerLayer => Ok(PodmanWorkspaceLocator::ContainerLayer),
1539 }
1540}
1541
1542pub fn workspace_for(template: &TargetTemplate, session_id: &str) -> Result<String> {
1543 validate_session_id(session_id)?;
1544 match template {
1545 TargetTemplate::LocalBare => bail!("local bare projects use their selected directory"),
1546 TargetTemplate::LocalPodman(_)
1547 | TargetTemplate::LocalDocker(_)
1548 | TargetTemplate::AppleContainer(_)
1549 | TargetTemplate::SshPodman { .. }
1550 | TargetTemplate::SshDocker { .. } => Ok(CONTAINER_WORKSPACE.to_owned()),
1551 TargetTemplate::AwsEc2(_) => Ok(format!(".local/share/hel/workspaces/{session_id}")),
1552 TargetTemplate::SshBare {
1553 workspace_prefix, ..
1554 } => {
1555 validate_workspace_prefix(workspace_prefix)?;
1556 let prefix = workspace_prefix
1561 .strip_prefix("~/")
1562 .unwrap_or(workspace_prefix);
1563 Ok(format!("{}/{session_id}", prefix.trim_end_matches('/')))
1564 }
1565 }
1566}
1567
1568pub fn command_on_locator(
1570 locator: &TargetLocator,
1571 session_id: &str,
1572 args: Vec<String>,
1573 purpose: impl Into<String>,
1574) -> Result<CommandSpec> {
1575 verify_locator(locator, session_id)?;
1576 if args.is_empty() {
1577 bail!("target command must not be empty");
1578 }
1579 let command = match locator {
1580 TargetLocator::LocalBare { .. } => {
1581 let mut args = args.into_iter();
1582 let program = args.next().expect("checked non-empty target command");
1583 CommandSpec::new(program, args)
1584 }
1585 TargetLocator::LocalPodman { container_id, .. } => {
1586 container_exec("podman", container_id, args)
1587 }
1588 TargetLocator::LocalDocker { container_id } => container_exec("docker", container_id, args),
1589 TargetLocator::AppleContainer { container_id } => {
1590 container_exec("container", container_id, args)
1591 }
1592 TargetLocator::AwsEc2 { ssh, .. } | TargetLocator::SshBare { ssh, .. } => {
1593 ssh_command_owned(ssh, args)
1594 }
1595 TargetLocator::SshPodman {
1596 ssh, container_id, ..
1597 }
1598 | TargetLocator::SshDocker { ssh, container_id } => {
1599 let mut remote = vec![
1600 locator
1601 .container_engine()
1602 .expect("remote container")
1603 .to_owned(),
1604 "exec".to_owned(),
1605 "-i".to_owned(),
1606 container_id.to_owned(),
1607 ];
1608 remote.extend(args);
1609 ssh_command_owned(ssh, remote)
1610 }
1611 };
1612 Ok(command.purpose(purpose))
1613}
1614pub fn worker_root(locator: &TargetLocator, session_id: &str) -> Result<String> {
1615 verify_locator(locator, session_id)?;
1616 Ok(match locator {
1617 TargetLocator::LocalBare { worker_root } => worker_root.clone(),
1618 TargetLocator::LocalPodman { .. }
1619 | TargetLocator::LocalDocker { .. }
1620 | TargetLocator::AppleContainer { .. }
1621 | TargetLocator::SshPodman { .. }
1622 | TargetLocator::SshDocker { .. } => format!("/var/lib/hel/workers/{session_id}"),
1623 TargetLocator::AwsEc2 { .. } => format!(".local/share/hel/workers/{session_id}"),
1624 TargetLocator::SshBare { worker_id, .. } => format!(
1625 ".local/share/hel/workers/{}",
1626 worker_id.as_deref().unwrap_or(session_id)
1627 ),
1628 })
1629}
1630mod ssh;
1631pub use ssh::*;
1632
1633pub fn container_exec(
1634 engine: &str,
1635 container_id: &str,
1636 args: impl IntoIterator<Item = impl Into<String>>,
1637) -> CommandSpec {
1638 let mut command_args = vec!["exec".to_owned(), "-i".to_owned(), container_id.to_owned()];
1639 command_args.extend(args.into_iter().map(Into::into));
1640 CommandSpec::new(engine, command_args)
1641}
1642
1643#[cfg(all(test, unix))]
1644mod executor_tests {
1645 use super::*;
1646
1647 fn flaky_ssh_script(directory: &Path) -> CommandSpec {
1651 let counter = directory.join("attempts");
1652 let script = format!(
1653 "count=$(cat {counter} 2>/dev/null || echo 0)\n\
1654 echo $((count + 1)) > {counter}\n\
1655 if [ \"$count\" -eq 0 ]; then\n\
1656 echo 'kex_exchange_identification: Connection closed by 10.0.0.1 port 22' >&2\n\
1657 exit 255\n\
1658 fi\n\
1659 echo connected\n",
1660 counter = counter.display()
1661 );
1662 CommandSpec::new("sh", ["-c".to_owned(), script])
1663 .ssh_destination("build@10.0.0.1")
1664 .purpose("run the flaky SSH fixture")
1665 }
1666
1667 fn attempts(directory: &Path) -> u32 {
1668 fs::read_to_string(directory.join("attempts"))
1669 .expect("the fixture records its attempts")
1670 .trim()
1671 .parse()
1672 .expect("attempt count is a number")
1673 }
1674
1675 #[test]
1676 fn a_transport_rejected_ssh_command_is_retried_once_and_then_succeeds() {
1677 set_ssh_retry_backoff_for_test(Some(Duration::from_millis(5)));
1678 let directory = tempfile::tempdir().expect("temp dir");
1679 let command = flaky_ssh_script(directory.path());
1680
1681 let output = ProcessExecutor
1682 .execute(&command)
1683 .expect("the retry must reach the successful attempt");
1684
1685 assert_eq!(output.status, 0);
1686 assert_eq!(String::from_utf8_lossy(&output.stdout).trim(), "connected");
1687 assert_eq!(attempts(directory.path()), 2);
1688 set_ssh_retry_backoff_for_test(None);
1689 }
1690
1691 #[test]
1692 fn an_untagged_command_is_not_retried_after_the_same_failure() {
1693 set_ssh_retry_backoff_for_test(Some(Duration::from_millis(5)));
1694 let directory = tempfile::tempdir().expect("temp dir");
1695 let mut command = flaky_ssh_script(directory.path());
1696 command.ssh_destination = None;
1697
1698 let output = ProcessExecutor.execute(&command).expect("runs once");
1699
1700 assert_eq!(output.status, 255);
1701 assert_eq!(attempts(directory.path()), 1);
1702 set_ssh_retry_backoff_for_test(None);
1703 }
1704
1705 #[test]
1706 fn the_cancellable_executor_also_retries_a_transport_rejection() {
1707 set_ssh_retry_backoff_for_test(Some(Duration::from_millis(5)));
1708 let directory = tempfile::tempdir().expect("temp dir");
1709 let command = flaky_ssh_script(directory.path());
1710
1711 let output = CancellableProcessExecutor::new(Arc::new(AtomicBool::new(false)))
1712 .execute(&command)
1713 .expect("the retry must reach the successful attempt");
1714
1715 assert_eq!(output.status, 0);
1716 assert_eq!(attempts(directory.path()), 2);
1717 set_ssh_retry_backoff_for_test(None);
1718 }
1719}