1use std::collections::{BTreeMap, BTreeSet};
8use std::io::{Read, Write};
9use std::path::{Path, PathBuf};
10use std::process::{Command, Stdio};
11use std::sync::Arc;
12use std::sync::atomic::{AtomicBool, Ordering};
13use std::time::{Duration, Instant};
14
15use anyhow::{Context, Result, bail, ensure};
16use serde::{Deserialize, Serialize};
17use sha2::{Digest, Sha256};
18
19use crate::config::{HarnessKind, ImagePullPolicy};
20
21mod diagnostics;
22pub use diagnostics::{BlockingOperation, BlockingOperationSnapshot, active_blocking_operations};
23
24pub const SESSION_LABEL: &str = "dev.mj.session";
25pub const MANAGED_LABEL: &str = "dev.mj.managed";
26pub const SESSION_TAG: &str = "dev.mj.session";
27pub const MANAGED_TAG: &str = "dev.mj.managed";
28pub const INSTANCE_LABEL: &str = "dev.mj.instance";
31pub const INSTANCE_TAG: &str = "dev.mj.instance";
32pub const CONTAINER_WORKSPACE: &str = "/workspace";
36
37#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
39pub enum ProvisionStage {
40 PullingImage,
43 Provisioning,
44 Booting,
45 Cloning,
46 Syncing,
47 Restoring,
48 Starting,
49 Installing(HarnessKind),
50 Compacting,
51 RecoveryCopy,
52 Verifying,
53 Closing,
54 StoppingTarget,
55 RemovingContainer,
56 RemovingStorage,
57 CleaningCache,
58}
59
60impl ProvisionStage {
61 pub fn label(self) -> String {
62 match self {
63 Self::PullingImage => "Pull image".into(),
64 Self::Provisioning => "Provision".into(),
65 Self::Booting => "Boot".into(),
66 Self::Cloning => "Clone".into(),
67 Self::Syncing => "Sync".into(),
68 Self::Restoring => "Restore".into(),
69 Self::Starting => "Start".into(),
70 Self::Installing(harness) => format!("Installing {}", harness.display_name()),
71 Self::Compacting => "Compact".into(),
72 Self::RecoveryCopy => "Recovery copy".into(),
73 Self::Verifying => "Verify".into(),
74 Self::Closing => "Shut down agent".into(),
75 Self::StoppingTarget => "Stop target".into(),
76 Self::RemovingContainer => "Remove container".into(),
77 Self::RemovingStorage => "Remove container storage".into(),
78 Self::CleaningCache => "Clean cache".into(),
79 }
80 }
81}
82
83#[derive(Clone, PartialEq, Eq)]
84struct SensitiveCommandInput(Vec<u8>);
85
86impl std::fmt::Debug for SensitiveCommandInput {
87 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
88 formatter.write_str("<redacted>")
89 }
90}
91
92#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
93pub struct CommandSpec {
94 pub program: String,
95 pub args: Vec<String>,
96 #[serde(default)]
97 pub env: BTreeMap<String, String>,
98 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
100 pub clear_env: bool,
101 #[serde(default, skip_serializing_if = "Option::is_none")]
102 pub cwd: Option<std::path::PathBuf>,
103 pub purpose: String,
104 #[serde(default)]
105 pub stage: Option<ProvisionStage>,
106 #[serde(default)]
111 pub parallel_group: Option<u32>,
112 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
116 pub creates_target: bool,
117 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
123 pub detaches: bool,
124 #[serde(default, skip_serializing_if = "Option::is_none")]
129 pub ssh_destination: Option<String>,
130 #[serde(default, skip_serializing_if = "Option::is_none")]
137 pub ssh_session: Option<SshTarget>,
138 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
141 pub ssh_session_probe: bool,
142 #[serde(skip)]
145 sensitive_stdin: Option<SensitiveCommandInput>,
146}
147
148impl CommandSpec {
149 pub fn new(
150 program: impl Into<String>,
151 args: impl IntoIterator<Item = impl Into<String>>,
152 ) -> Self {
153 Self {
154 program: program.into(),
155 args: args.into_iter().map(Into::into).collect(),
156 env: BTreeMap::new(),
157 clear_env: false,
158 cwd: None,
159 purpose: String::new(),
160 stage: None,
161 parallel_group: None,
162 creates_target: false,
163 detaches: false,
164 ssh_destination: None,
165 ssh_session: None,
166 ssh_session_probe: false,
167 sensitive_stdin: None,
168 }
169 }
170
171 pub fn purpose(mut self, purpose: impl Into<String>) -> Self {
172 self.purpose = purpose.into();
173 self
174 }
175
176 pub fn stage(mut self, stage: ProvisionStage) -> Self {
177 self.stage = Some(stage);
178 self
179 }
180
181 pub fn parallel_group(mut self, group: u32) -> Self {
184 self.parallel_group = Some(group);
185 self
186 }
187
188 pub fn ssh_destination(mut self, destination: impl Into<String>) -> Self {
191 self.ssh_destination = Some(destination.into());
192 self
193 }
194
195 pub fn ssh_session(mut self, ssh: &SshTarget) -> Self {
199 self.ssh_destination = Some(ssh.destination.clone());
200 self.ssh_session = Some(ssh.clone());
201 self
202 }
203
204 pub fn ssh_probe_session(mut self, ssh: &SshTarget) -> Self {
211 self = self.ssh_session(ssh);
212 self.ssh_session_probe = true;
213 self
214 }
215
216 pub fn open_ssh_session(&self, executor: &dyn CommandExecutor) -> Result<SessionCommand<'_>> {
222 let Some(ssh) = &self.ssh_session else {
223 return Ok(SessionCommand {
224 command: std::borrow::Cow::Borrowed(self),
225 lease: None,
226 });
227 };
228 let _waiting = BlockingOperation::start(
229 &format!(
230 "lease SSH connection to {} for {}",
231 ssh.destination, self.purpose
232 ),
233 "ssh",
234 );
235 let lease = if self.ssh_session_probe {
236 SshSessions::lease_probe(ssh)
237 } else {
238 SshSessions::lease(ssh, executor)?
239 };
240 let mut command = self.clone();
241 command.ssh_session = None;
242 command.ssh_session_probe = false;
243 command.args = session_command_args(&self.program, &self.args, ssh, &lease);
244 Ok(SessionCommand {
245 command: std::borrow::Cow::Owned(command),
246 lease: Some(lease),
247 })
248 }
249
250 pub fn creates_target(mut self) -> Self {
252 self.creates_target = true;
253 self
254 }
255
256 pub fn with_sensitive_stdin(mut self, input: Vec<u8>) -> Self {
259 self.sensitive_stdin = Some(SensitiveCommandInput(input));
260 self
261 }
262}
263
264#[derive(Debug)]
268pub struct SessionCommand<'a> {
269 command: std::borrow::Cow<'a, CommandSpec>,
270 lease: Option<SshSessionLease>,
271}
272
273impl SessionCommand<'_> {
274 pub fn command(&self) -> &CommandSpec {
275 &self.command
276 }
277
278 pub fn lease(&self) -> Option<&SshSessionLease> {
279 self.lease.as_ref()
280 }
281
282 pub fn into_parts(self) -> (CommandSpec, Option<SshSessionLease>) {
283 (self.command.into_owned(), self.lease)
284 }
285}
286
287#[derive(Debug, Clone, PartialEq, Eq)]
288pub struct CommandOutput {
289 pub status: i32,
290 pub stdout: Vec<u8>,
291 pub stderr: Vec<u8>,
292}
293
294#[derive(Debug, Clone, Copy, PartialEq, Eq)]
295pub enum DeploymentCapacityKind {
296 Host,
297 AwsFleet,
298}
299
300#[derive(Debug, Clone, PartialEq, Eq)]
301pub struct DeploymentCapacityTarget {
302 pub id: String,
303 pub host: String,
304 pub target_ids: Vec<String>,
305 pub kind: DeploymentCapacityKind,
306 pub local: bool,
307 pub probes: Vec<CommandSpec>,
309 pub probe_error: Option<String>,
311}
312
313#[derive(Debug, Clone, PartialEq, Eq)]
314pub struct DeploymentCapacityUsage {
315 pub cpu_percent: Option<u8>,
316 pub memory_used_bytes: u64,
317 pub memory_total_bytes: u64,
318 pub logical_cores: u64,
319 pub disk_total_bytes: Option<u64>,
320}
321
322#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
328#[serde(from = "AdditionalMountRepr", into = "AdditionalMountRepr")]
329pub struct AdditionalMount {
330 pub source: PathBuf,
331 pub destination: PathBuf,
332 pub access: MountAccess,
333}
334
335#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
337#[serde(rename_all = "snake_case")]
338pub enum MountAccess {
339 Ro,
341 Cow,
344 Rw,
346}
347
348impl MountAccess {
349 pub const ALL: [Self; 3] = [Self::Ro, Self::Cow, Self::Rw];
350
351 pub fn label(self) -> &'static str {
352 match self {
353 Self::Ro => "ro",
354 Self::Cow => "cow",
355 Self::Rw => "rw",
356 }
357 }
358
359 pub fn without_overlay(self) -> Self {
368 match self {
369 Self::Cow => Self::Ro,
370 kept => kept,
371 }
372 }
373
374 pub fn offered(overlay_available: bool) -> Vec<Self> {
377 Self::ALL
378 .into_iter()
379 .filter(|access| overlay_available || access.without_overlay() == *access)
380 .collect()
381 }
382}
383
384#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
387pub struct ImageUser {
388 pub uid: u32,
389 pub gid: u32,
390}
391
392pub fn podman_userns_option(image_user: Option<ImageUser>) -> Option<String> {
403 image_user.map(|ImageUser { uid, gid }| format!("--userns=keep-id:uid={uid},gid={gid}"))
404}
405
406#[derive(Serialize, Deserialize)]
411#[serde(deny_unknown_fields)]
412struct AdditionalMountRepr {
413 source: PathBuf,
414 destination: PathBuf,
415 #[serde(default)]
416 read_only: bool,
417 #[serde(default, skip_serializing_if = "Option::is_none")]
418 access: Option<MountAccess>,
419}
420
421impl From<AdditionalMountRepr> for AdditionalMount {
422 fn from(repr: AdditionalMountRepr) -> Self {
423 let access = repr.access.unwrap_or(if repr.read_only {
424 MountAccess::Ro
425 } else {
426 MountAccess::Cow
427 });
428 Self {
429 source: repr.source,
430 destination: repr.destination,
431 access,
432 }
433 }
434}
435
436impl From<AdditionalMount> for AdditionalMountRepr {
437 fn from(mount: AdditionalMount) -> Self {
438 Self {
439 source: mount.source,
440 destination: mount.destination,
441 read_only: mount.access == MountAccess::Ro,
442 access: (mount.access == MountAccess::Rw).then_some(MountAccess::Rw),
443 }
444 }
445}
446
447pub fn overlay_unsupported_filesystem(filesystem: &str) -> Option<&'static str> {
453 let name = filesystem.trim().to_ascii_lowercase();
454 if name == "fuse" || name == "fuseblk" || name.starts_with("fuse.") {
456 return Some("FUSE filesystem");
457 }
458 match name.as_str() {
459 "nfs" | "nfs4" | "cifs" | "smb2" | "smb3" | "9p" | "v9fs" | "virtiofs" | "ceph"
460 | "lustre" | "afs" | "glusterfs" | "ocfs2" | "gfs" | "gfs2" => Some("network filesystem"),
461 "msdos" | "vfat" | "fat" | "exfat" | "ntfs" | "ntfs3" => Some("no POSIX metadata"),
462 "overlayfs" => Some("overlay stacking limit"),
463 _ => None,
464 }
465}
466
467pub fn validate_mount_destination(path: &Path) -> Result<()> {
469 ensure!(
470 path.is_absolute()
471 && !path
472 .components()
473 .any(|part| part == std::path::Component::ParentDir),
474 "additional mount destination must be a safe absolute container path; ~ is not supported"
475 );
476 Ok(())
477}
478
479pub fn validate_additional_mounts(mounts: &[AdditionalMount]) -> Result<()> {
480 let mut destinations = BTreeSet::new();
481 for mount in mounts {
482 if !mount.source.is_absolute() || mount.source.as_os_str().is_empty() {
483 bail!("additional mount source must be an absolute directory path");
484 }
485 validate_mount_destination(&mount.destination)?;
486 if !destinations.insert(mount.destination.clone()) {
487 bail!(
488 "additional mount destination {:?} is configured more than once",
489 mount.destination
490 );
491 }
492 }
493 Ok(())
494}
495
496pub fn default_mount_destination(source: &Path, existing: &[AdditionalMount]) -> PathBuf {
498 let basename = source
499 .file_name()
500 .filter(|name| !name.is_empty())
501 .unwrap_or_else(|| std::ffi::OsStr::new("mount"));
502 let base = PathBuf::from("/mnt").join(basename);
503 if !existing.iter().any(|mount| mount.destination == base) {
504 return base;
505 }
506 for number in 2.. {
507 let candidate =
508 PathBuf::from("/mnt").join(format!("{}-{number}", basename.to_string_lossy()));
509 if !existing.iter().any(|mount| mount.destination == candidate) {
510 return candidate;
511 }
512 }
513 unreachable!("a finite mount list always has an unused numbered destination")
514}
515
516pub trait CommandExecutor {
517 fn execute(&self, command: &CommandSpec) -> Result<CommandOutput>;
518
519 fn cancellation_requested(&self) -> bool {
523 false
524 }
525
526 fn stage_started(&self, _stage: ProvisionStage) {}
530
531 fn stage_finished(&self, _stage: ProvisionStage) {}
534
535 fn notify_notice(&self, _notice: &str) {}
538
539 fn reserve_move_destination(&self) {}
542
543 fn begin_resumable_move_work(&self) -> Result<()> {
545 Ok(())
546 }
547 fn end_resumable_move_work(&self) -> Result<()> {
548 Ok(())
549 }
550
551 fn execute_with_stdin(
552 &self,
553 _command: &CommandSpec,
554 _input: &mut (dyn Read + Send),
555 ) -> Result<CommandOutput> {
556 bail!("this command executor does not support streamed stdin")
557 }
558}
559
560pub struct ProvisionStageGuard<'a, E: CommandExecutor + ?Sized> {
564 executor: &'a E,
565 stage: ProvisionStage,
566}
567
568impl<'a, E: CommandExecutor + ?Sized> ProvisionStageGuard<'a, E> {
569 pub fn new(executor: &'a E, stage: ProvisionStage) -> Self {
570 executor.stage_started(stage);
571 Self { executor, stage }
572 }
573}
574
575impl<E: CommandExecutor + ?Sized> Drop for ProvisionStageGuard<'_, E> {
576 fn drop(&mut self) {
577 self.executor.stage_finished(self.stage);
578 }
579}
580
581pub struct ProcessExecutor;
582
583fn with_ssh_admission(
599 command: &CommandSpec,
600 executor: &dyn CommandExecutor,
601 is_cancelled: &dyn Fn() -> bool,
602 mut run: impl FnMut(&CommandSpec) -> Result<CommandOutput>,
603) -> Result<CommandOutput> {
604 let Some(destination) = command.ssh_destination.as_deref() else {
605 return run(command);
606 };
607 for attempt in 1..=SSH_RETRY_ATTEMPTS {
608 let session = command.open_ssh_session(executor)?;
609 let output = {
610 let _permit = SshAdmission::acquire_unless(destination, is_cancelled)?;
611 run(session.command())?
612 };
613 let refusal = ssh_refusal(output.status, &String::from_utf8_lossy(&output.stderr));
614 if refusal == Some(SshRefusal::BeforeAuthentication)
617 && let Some(lease) = session.lease()
618 {
619 lease.invalidate();
620 }
621 drop(session);
622 let Some(refusal) = refusal else {
623 return Ok(output);
624 };
625 let stderr = String::from_utf8_lossy(&output.stderr);
626 if attempt == SSH_RETRY_ATTEMPTS {
627 refusal.log_exhausted(destination, &command.purpose, stderr.trim());
628 return Ok(output);
629 }
630 let delay = ssh_retry_delay(attempt);
631 refusal.log_retry(destination, &command.purpose, attempt, delay, stderr.trim());
632 if !sleep_unless_cancelled(delay, is_cancelled) {
633 bail!("operation cancelled while {}", command.purpose);
634 }
635 }
636 unreachable!("the final attempt always returns");
637}
638
639fn sleep_unless_cancelled(delay: Duration, is_cancelled: &dyn Fn() -> bool) -> bool {
642 let deadline = Instant::now() + delay;
643 loop {
644 if is_cancelled() {
645 return false;
646 }
647 let remaining = deadline.saturating_duration_since(Instant::now());
648 if remaining.is_zero() {
649 return true;
650 }
651 std::thread::sleep(remaining.min(Duration::from_millis(50)));
652 }
653}
654
655pub fn trace_command_duration(command: &CommandSpec, started: Instant, status: i32) {
658 tracing::debug!(
659 purpose = command.purpose.as_str(),
660 program = command.program.as_str(),
661 status,
662 elapsed_ms = started.elapsed().as_millis() as u64,
663 "target command finished"
664 );
665}
666
667impl ProcessExecutor {
668 fn run_once(&self, command: &CommandSpec) -> Result<CommandOutput> {
670 if let Some(input) = &command.sensitive_stdin {
671 let mut input = std::io::Cursor::new(input.0.as_slice());
672 return stream_command_with_stdin(
674 cancellable_command(command),
675 command,
676 &mut input,
677 &|| false,
678 );
679 }
680 let started = Instant::now();
681 let output = configured_command(command)
682 .stdin(Stdio::null())
683 .output()
684 .with_context(|| format!("run {} for {}", command.program, command.purpose))?;
685 let status = output.status.code().unwrap_or(-1);
686 trace_command_duration(command, started, status);
687 Ok(CommandOutput {
688 status,
689 stdout: output.stdout,
690 stderr: output.stderr,
691 })
692 }
693}
694
695impl CommandExecutor for ProcessExecutor {
696 fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
697 let _running = BlockingOperation::command(command);
698 with_ssh_admission(command, self, &|| false, |command| self.run_once(command))
699 }
700
701 fn execute_with_stdin(
702 &self,
703 command: &CommandSpec,
704 input: &mut (dyn Read + Send),
705 ) -> Result<CommandOutput> {
706 let _running = BlockingOperation::command(command);
707 let session = command.open_ssh_session(self)?;
710 let _permit = command
711 .ssh_destination
712 .as_deref()
713 .map(SshAdmission::acquire);
714 let command = session.command();
715 let process = cancellable_command(command);
716 stream_command_with_stdin(process, command, input, &|| false)
719 }
720}
721
722fn stream_command_with_stdin(
731 mut process: Command,
732 command: &CommandSpec,
733 input: &mut (dyn Read + Send),
734 is_cancelled: &(dyn Fn() -> bool + Sync),
735) -> Result<CommandOutput> {
736 let started = Instant::now();
737 if is_cancelled() {
738 bail!("operation cancelled");
739 }
740 let mut child = process
741 .stdin(Stdio::piped())
742 .stdout(Stdio::piped())
743 .stderr(Stdio::piped())
744 .spawn()
745 .with_context(|| format!("run {} for {}", command.program, command.purpose))?;
746 let group =
747 (!command.detaches).then(|| crate::subprocess::ProcessGroupGuard::new(Some(child.id())));
748 let stdin = child
749 .stdin
750 .take()
751 .context("streamed command stdin missing")?;
752 let stdout = child
753 .stdout
754 .take()
755 .context("streamed command stdout missing")?;
756 let stderr = child
757 .stderr
758 .take()
759 .context("streamed command stderr missing")?;
760 let stdout_reader = PipeCollector::spawn(stdout);
763 let stderr_reader = PipeCollector::spawn(stderr);
764 let process_result = std::thread::scope(|scope| -> Result<_> {
765 let input_writer = scope.spawn(move || -> Result<()> {
769 let mut stdin = stdin;
774 let mut buffer = [0_u8; 64 * 1024];
775 loop {
776 if is_cancelled() {
780 bail!("operation cancelled");
781 }
782 let count = input.read(&mut buffer).context("read command input")?;
783 if count == 0 {
784 break;
785 }
786 stdin
787 .write_all(&buffer[..count])
788 .context("stream command input")?;
789 }
790 stdin.flush().context("flush command input")
791 });
792 let mut status = None;
793 let mut exited_at = None;
794 let mut group_killed = false;
795 let status = loop {
796 if is_cancelled() {
797 terminate_cancellable_child(&mut child);
798 if let Err(error) = input_writer.join() {
799 tracing::warn!(
800 purpose = command.purpose.as_str(),
801 "streamed command input writer panicked while cancelling: {error:?}"
802 );
803 }
804 bail!("operation cancelled while {}", command.purpose);
805 }
806 match if status.is_some() {
807 Ok(status)
808 } else {
809 child.try_wait()
810 } {
811 Ok(observed) => status = observed,
812 Err(error) => {
813 terminate_cancellable_child(&mut child);
814 if let Err(join_error) = input_writer.join() {
815 tracing::warn!(
816 purpose = command.purpose.as_str(),
817 "streamed command input writer panicked while waiting: {join_error:?}"
818 );
819 }
820 return Err(error).with_context(|| format!("wait for {}", command.purpose));
821 }
822 }
823 if let Some(status) = status {
828 let exited_at = *exited_at.get_or_insert_with(Instant::now);
829 let drained = (stdout_reader.is_finished() && stderr_reader.is_finished())
830 || exited_at.elapsed() >= IO_DRAIN_TIMEOUT;
831 if drained && !input_writer.is_finished() && !group_killed {
832 group_killed = true;
833 if let Some(group) = &group {
834 group.kill();
835 }
836 }
837 if drained && input_writer.is_finished() {
838 break status;
839 }
840 }
841 std::thread::sleep(Duration::from_millis(25));
842 };
843 let input_result = input_writer
844 .join()
845 .map_err(|_| anyhow::anyhow!("streamed command input writer panicked"))?;
846 Ok((status, input_result))
847 });
848 let deadline = Instant::now();
849 let stdout = stdout_reader.finish("stdout", deadline)?;
850 let stderr = stderr_reader.finish("stderr", deadline)?;
851 let (status, input_result) = process_result?;
852 drop(group);
853 if status.success() {
854 input_result?;
858 }
859 let status = status.code().unwrap_or(-1);
860 trace_command_duration(command, started, status);
861 Ok(CommandOutput {
862 status,
863 stdout,
864 stderr,
865 })
866}
867
868#[derive(Clone)]
869pub struct CancellableProcessExecutor {
870 cancelled: Arc<AtomicBool>,
871 deadline: Option<Instant>,
872}
873
874pub struct ProcessCancellationGuard(Arc<AtomicBool>);
876
877impl Drop for ProcessCancellationGuard {
878 fn drop(&mut self) {
879 self.0.store(true, Ordering::Release);
880 }
881}
882
883impl CancellableProcessExecutor {
884 pub fn cancel_on_drop(&self) -> ProcessCancellationGuard {
885 ProcessCancellationGuard(self.cancelled.clone())
886 }
887
888 pub fn new(cancelled: Arc<AtomicBool>) -> Self {
889 Self {
890 cancelled,
891 deadline: None,
892 }
893 }
894
895 pub fn is_cancelled(&self) -> bool {
896 self.cancelled.load(Ordering::Acquire)
897 || self
898 .deadline
899 .is_some_and(|deadline| Instant::now() >= deadline)
900 }
901
902 pub fn with_timeout(timeout: Duration) -> Self {
903 Self {
904 cancelled: Arc::new(AtomicBool::new(false)),
905 deadline: Some(Instant::now() + timeout),
906 }
907 }
908
909 pub fn with_deadline(mut self, timeout: Duration) -> Self {
912 self.deadline = Some(Instant::now() + timeout);
913 self
914 }
915
916 fn check_cancelled(&self) -> Result<()> {
917 if self.is_cancelled() {
918 bail!("operation cancelled");
919 }
920 Ok(())
921 }
922}
923
924fn configured_command(command: &CommandSpec) -> Command {
925 let mut process = Command::new(&command.program);
926 if command.clear_env {
927 process.env_clear();
928 }
929 if let Some(cwd) = &command.cwd {
930 process.current_dir(cwd);
931 }
932 process.args(&command.args).envs(&command.env);
933 process
934}
935
936fn cancellable_command(command: &CommandSpec) -> Command {
937 #[cfg(unix)]
938 let mut process = configured_command(command);
939 #[cfg(not(unix))]
940 let process = configured_command(command);
941 #[cfg(unix)]
942 {
943 use std::os::unix::process::CommandExt as _;
944 process.process_group(0);
945 }
946 process
947}
948
949const IO_DRAIN_TIMEOUT: Duration = Duration::from_secs(2);
955
956trait PollablePipe: Read + Send + 'static {
959 fn readable(&self, timeout: Duration) -> bool;
960}
961
962macro_rules! pollable_pipe {
963 ($pipe:ty) => {
964 impl PollablePipe for $pipe {
965 #[cfg(unix)]
966 fn readable(&self, timeout: Duration) -> bool {
967 use std::os::fd::AsRawFd as _;
968 let mut poll = libc::pollfd {
969 fd: self.as_raw_fd(),
970 events: libc::POLLIN,
971 revents: 0,
972 };
973 let millis = i32::try_from(timeout.as_millis()).unwrap_or(i32::MAX);
974 unsafe { libc::poll(&raw mut poll, 1, millis) > 0 }
976 }
977
978 #[cfg(not(unix))]
980 fn readable(&self, _timeout: Duration) -> bool {
981 true
982 }
983 }
984 };
985}
986
987pollable_pipe!(std::process::ChildStdout);
988pollable_pipe!(std::process::ChildStderr);
989
990struct PipeCollector {
994 bytes: Arc<std::sync::Mutex<Vec<u8>>>,
995 stop: Arc<AtomicBool>,
996 thread: Option<std::thread::JoinHandle<std::io::Result<()>>>,
997}
998
999impl PipeCollector {
1000 fn spawn(mut pipe: impl PollablePipe) -> Self {
1001 let bytes = Arc::new(std::sync::Mutex::new(Vec::new()));
1002 let stop = Arc::new(AtomicBool::new(false));
1003 let (collected, stopped) = (bytes.clone(), stop.clone());
1004 let thread = std::thread::spawn(move || {
1005 let mut chunk = [0_u8; 8192];
1006 while !stopped.load(Ordering::Acquire) {
1007 if !pipe.readable(Duration::from_millis(25)) {
1008 continue;
1009 }
1010 match pipe.read(&mut chunk) {
1011 Ok(0) => break,
1012 Ok(count) => collected
1013 .lock()
1014 .unwrap_or_else(std::sync::PoisonError::into_inner)
1015 .extend_from_slice(&chunk[..count]),
1016 Err(error) if error.kind() == std::io::ErrorKind::Interrupted => {}
1017 Err(error) => return Err(error),
1018 }
1019 }
1020 Ok(())
1021 });
1022 Self {
1023 bytes,
1024 stop,
1025 thread: Some(thread),
1026 }
1027 }
1028
1029 fn is_finished(&self) -> bool {
1030 self.thread
1031 .as_ref()
1032 .is_none_or(std::thread::JoinHandle::is_finished)
1033 }
1034
1035 fn finish(mut self, stream: &str, deadline: Instant) -> Result<Vec<u8>> {
1038 while !self.is_finished() && Instant::now() < deadline {
1039 std::thread::sleep(Duration::from_millis(5));
1040 }
1041 self.stop.store(true, Ordering::Release);
1042 self.thread
1043 .take()
1044 .context("command reader already joined")?
1045 .join()
1046 .map_err(|_| anyhow::anyhow!("command {stream} reader panicked"))?
1047 .with_context(|| format!("read command {stream}"))?;
1048 let mut bytes = self
1049 .bytes
1050 .lock()
1051 .unwrap_or_else(std::sync::PoisonError::into_inner);
1052 Ok(std::mem::take(&mut bytes))
1053 }
1054}
1055
1056impl Drop for PipeCollector {
1057 fn drop(&mut self) {
1058 self.stop.store(true, Ordering::Release);
1059 }
1060}
1061
1062fn terminate_cancellable_child(child: &mut std::process::Child) {
1063 #[cfg(unix)]
1064 if let Err(error) = crate::subprocess::signal_process_group(child.id() as i32, libc::SIGKILL) {
1069 tracing::warn!(pid = child.id(), %error, "could not terminate cancelled command process group");
1070 }
1071 #[cfg(not(unix))]
1072 if let Err(error) = child.kill() {
1073 tracing::warn!(pid = child.id(), %error, "could not terminate cancelled command");
1074 }
1075 if let Err(error) = child.wait() {
1076 tracing::warn!(pid = child.id(), %error, "could not reap cancelled command");
1077 }
1078}
1079
1080impl CancellableProcessExecutor {
1081 fn run_once(&self, command: &CommandSpec) -> Result<CommandOutput> {
1083 if let Some(input) = &command.sensitive_stdin {
1084 let mut input = std::io::Cursor::new(input.0.as_slice());
1085 return stream_command_with_stdin(
1087 cancellable_command(command),
1088 command,
1089 &mut input,
1090 &|| self.is_cancelled(),
1091 );
1092 }
1093 let started = Instant::now();
1094 self.check_cancelled()?;
1095 let mut child = cancellable_command(command)
1096 .stdin(Stdio::null())
1097 .stdout(Stdio::piped())
1098 .stderr(Stdio::piped())
1099 .spawn()
1100 .with_context(|| format!("run {} for {}", command.program, command.purpose))?;
1101 let group = (!command.detaches)
1102 .then(|| crate::subprocess::ProcessGroupGuard::new(Some(child.id())));
1103 let stdout = child.stdout.take().context("command stdout missing")?;
1104 let stderr = child.stderr.take().context("command stderr missing")?;
1105 let stdout_reader = PipeCollector::spawn(stdout);
1106 let stderr_reader = PipeCollector::spawn(stderr);
1107 let mut status = None;
1108 let mut exited_at = None;
1109 let status = loop {
1110 if self.is_cancelled() {
1111 terminate_cancellable_child(&mut child);
1112 let deadline = Instant::now() + IO_DRAIN_TIMEOUT;
1113 for (stream, reader) in [("stdout", stdout_reader), ("stderr", stderr_reader)] {
1114 if let Err(error) = reader.finish(stream, deadline) {
1115 tracing::warn!(stream, %error, "cancelled command reader failed");
1116 }
1117 }
1118 bail!("operation cancelled while {}", command.purpose);
1119 }
1120 if status.is_none() {
1121 status = child
1122 .try_wait()
1123 .with_context(|| format!("wait for {}", command.purpose))?;
1124 }
1125 if let Some(status) = status {
1128 let exited_at = *exited_at.get_or_insert_with(Instant::now);
1129 if (stdout_reader.is_finished() && stderr_reader.is_finished())
1130 || exited_at.elapsed() >= IO_DRAIN_TIMEOUT
1131 {
1132 break status;
1133 }
1134 }
1135 std::thread::sleep(Duration::from_millis(25));
1136 };
1137 let deadline = Instant::now();
1138 let stdout = stdout_reader.finish("stdout", deadline)?;
1139 let stderr = stderr_reader.finish("stderr", deadline)?;
1140 let status = status.code().unwrap_or(-1);
1141 drop(group);
1142 trace_command_duration(command, started, status);
1143 Ok(CommandOutput {
1144 status,
1145 stdout,
1146 stderr,
1147 })
1148 }
1149}
1150
1151impl CommandExecutor for CancellableProcessExecutor {
1152 fn cancellation_requested(&self) -> bool {
1153 self.is_cancelled()
1154 }
1155
1156 fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
1157 let _running = BlockingOperation::command(command);
1158 with_ssh_admission(command, self, &|| self.is_cancelled(), |command| {
1159 self.run_once(command)
1160 })
1161 }
1162
1163 fn execute_with_stdin(
1164 &self,
1165 command: &CommandSpec,
1166 input: &mut (dyn Read + Send),
1167 ) -> Result<CommandOutput> {
1168 let _running = BlockingOperation::command(command);
1169 let session = command.open_ssh_session(self)?;
1172 let _permit = command
1173 .ssh_destination
1174 .as_deref()
1175 .map(|destination| SshAdmission::acquire_unless(destination, &|| self.is_cancelled()))
1176 .transpose()?;
1177 let command = session.command();
1178 stream_command_with_stdin(cancellable_command(command), command, input, &|| {
1181 self.is_cancelled()
1182 })
1183 }
1184}
1185
1186#[derive(Debug, Clone, PartialEq, Eq)]
1190pub struct CommandTimedOut {
1191 pub program: String,
1192 pub purpose: String,
1193 pub timeout: Duration,
1194}
1195
1196impl std::fmt::Display for CommandTimedOut {
1197 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1198 write!(
1199 formatter,
1200 "`{}` did not answer within {} seconds while trying to {}",
1201 self.program,
1202 self.timeout.as_secs(),
1203 self.purpose
1204 )
1205 }
1206}
1207
1208impl std::error::Error for CommandTimedOut {}
1209
1210#[derive(Debug, Clone, Copy)]
1219pub struct BoundedProcessExecutor {
1220 timeout: Duration,
1221}
1222
1223impl BoundedProcessExecutor {
1224 pub const fn new(timeout: Duration) -> Self {
1225 Self { timeout }
1226 }
1227}
1228
1229impl CommandExecutor for BoundedProcessExecutor {
1230 fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
1231 let executor = CancellableProcessExecutor::with_timeout(self.timeout);
1232 executor.execute(command).map_err(|error| {
1233 if executor.is_cancelled() {
1234 anyhow::Error::new(CommandTimedOut {
1235 program: command.program.clone(),
1236 purpose: command.purpose.clone(),
1237 timeout: self.timeout,
1238 })
1239 } else {
1240 error
1241 }
1242 })
1243 }
1244
1245 fn execute_with_stdin(
1246 &self,
1247 command: &CommandSpec,
1248 input: &mut (dyn Read + Send),
1249 ) -> Result<CommandOutput> {
1250 CancellableProcessExecutor::with_timeout(self.timeout).execute_with_stdin(command, input)
1251 }
1252}
1253
1254#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1255pub struct CommandPlan {
1256 pub description: String,
1257 pub commands: Vec<CommandSpec>,
1258}
1259
1260impl CommandPlan {
1261 pub fn provide_target_environment_secret(
1265 &mut self,
1266 target: &TargetTemplate,
1267 name: &str,
1268 value: &str,
1269 ) -> Result<()> {
1270 ensure!(
1271 !name.is_empty()
1272 && name.bytes().enumerate().all(|(index, byte)| byte == b'_'
1273 || byte.is_ascii_alphabetic()
1274 || (index > 0 && byte.is_ascii_digit())),
1275 "invalid secret environment variable name"
1276 );
1277 ensure!(
1278 !value.as_bytes().contains(&b'\n') && !value.as_bytes().contains(&b'\r'),
1279 "secret environment value cannot contain a newline"
1280 );
1281 let command = self
1282 .commands
1283 .iter_mut()
1284 .find(|command| command.creates_target)
1285 .context("provisioning plan has no target creation command")?;
1286 let read_and_export = format!("IFS= read -r {name} || exit 1; export {name};");
1287 match target {
1288 TargetTemplate::LocalPodman(_)
1289 | TargetTemplate::LocalDocker(_)
1290 | TargetTemplate::AppleContainer(_) => {
1291 let program = std::mem::replace(&mut command.program, "sh".to_owned());
1292 let args = std::mem::take(&mut command.args);
1293 command.args = vec![
1294 "-c".to_owned(),
1295 format!("{read_and_export} exec \"$@\""),
1296 "mj-secret-env".to_owned(),
1297 program,
1298 ];
1299 command.args.extend(args);
1300 }
1301 TargetTemplate::SshPodman { .. } | TargetTemplate::SshDocker { .. } => {
1302 let remote = command
1303 .args
1304 .last_mut()
1305 .context("remote container command has no SSH command argument")?;
1306 *remote = format!("{read_and_export} exec {remote}");
1307 }
1308 TargetTemplate::LocalBare
1309 | TargetTemplate::AwsEc2(_)
1310 | TargetTemplate::SshBare { .. } => {
1311 bail!("target does not support inherited container environment")
1312 }
1313 }
1314 let mut input = value.as_bytes().to_vec();
1315 input.push(b'\n');
1316 command.sensitive_stdin = Some(SensitiveCommandInput(input));
1317 Ok(())
1318 }
1319
1320 pub fn execute(&self, executor: &impl CommandExecutor) -> Result<Vec<CommandOutput>> {
1321 let mut outputs = Vec::with_capacity(self.commands.len());
1322 for command in &self.commands {
1323 let output = executor.execute(command)?;
1324 if output.status != 0 {
1325 bail!(
1326 "{} failed with status {}: {}",
1327 command.purpose,
1328 output.status,
1329 String::from_utf8_lossy(&output.stderr)
1330 );
1331 }
1332 outputs.push(output);
1333 }
1334 Ok(outputs)
1335 }
1336
1337 pub fn execute_concurrent(
1349 &self,
1350 executor: &(impl CommandExecutor + Sync),
1351 ) -> Result<Vec<CommandOutput>> {
1352 let mut outputs = Vec::with_capacity(self.commands.len());
1353 let mut index = 0;
1354 while index < self.commands.len() {
1355 let group = self.commands[index].parallel_group;
1356 let mut end = index + 1;
1357 if group.is_some() {
1358 while end < self.commands.len() && self.commands[end].parallel_group == group {
1359 end += 1;
1360 }
1361 }
1362 let batch = &self.commands[index..end];
1363 if let [command] = batch {
1364 outputs.push(checked_command_output(command, executor.execute(command)?)?);
1365 } else {
1366 let results: Vec<Result<CommandOutput>> = std::thread::scope(|scope| {
1367 let handles: Vec<_> = batch
1368 .iter()
1369 .map(|command| scope.spawn(|| executor.execute(command)))
1370 .collect();
1371 handles
1372 .into_iter()
1373 .map(|handle| match handle.join() {
1374 Ok(result) => result,
1375 Err(panic) => Err(anyhow::anyhow!(
1376 "concurrent command thread panicked: {}",
1377 command_thread_panic_message(panic.as_ref())
1378 )),
1379 })
1380 .collect()
1381 });
1382 for (command, result) in batch.iter().zip(results) {
1383 outputs.push(checked_command_output(command, result?)?);
1384 }
1385 }
1386 index = end;
1387 }
1388 Ok(outputs)
1389 }
1390
1391 pub fn split_at_target_creation(&self) -> Option<(Self, Self)> {
1399 let created = self
1400 .commands
1401 .iter()
1402 .position(|command| command.creates_target)?;
1403 let (creation, remainder) = self.commands.split_at(created + 1);
1404 Some((
1405 Self {
1406 description: self.description.clone(),
1407 commands: creation.to_vec(),
1408 },
1409 Self {
1410 description: self.description.clone(),
1411 commands: remainder.to_vec(),
1412 },
1413 ))
1414 }
1415}
1416
1417pub fn checked_command_output(
1421 command: &CommandSpec,
1422 output: CommandOutput,
1423) -> Result<CommandOutput> {
1424 if output.status != 0 {
1425 bail!(
1426 "{} failed with status {}: {}",
1427 command.purpose,
1428 output.status,
1429 String::from_utf8_lossy(&output.stderr)
1430 );
1431 }
1432 Ok(output)
1433}
1434
1435pub fn command_thread_panic_message(payload: &(dyn std::any::Any + Send)) -> String {
1437 if let Some(message) = payload.downcast_ref::<&str>() {
1438 (*message).to_owned()
1439 } else if let Some(message) = payload.downcast_ref::<String>() {
1440 message.clone()
1441 } else {
1442 "non-string panic payload".to_owned()
1443 }
1444}
1445
1446#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1447pub struct RepositorySpec {
1448 pub url: Option<String>,
1450 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1451 pub push_urls: Vec<String>,
1452 pub destination: String,
1453 pub git_ref: Option<String>,
1454 #[serde(default, skip_serializing_if = "Option::is_none")]
1457 pub reference: Option<String>,
1458}
1459
1460#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1461pub struct ProjectBundleSpec {
1462 pub primary: String,
1463 pub repositories: Vec<RepositorySpec>,
1464}
1465
1466impl ProjectBundleSpec {
1467 pub fn validate(&self) -> Result<()> {
1468 validate_relative_path(&self.primary)?;
1469 if self.repositories.is_empty() {
1470 bail!("a project bundle must contain at least one repository");
1471 }
1472 let mut destinations = std::collections::BTreeSet::new();
1473 for repository in &self.repositories {
1474 validate_relative_path(&repository.destination)?;
1475 ensure!(
1476 repository
1477 .url
1478 .as_deref()
1479 .is_some_and(|url| !url.trim().is_empty() && !url.starts_with('-')),
1480 "isolated repositories require a network Git remote; configure a remote or use a raw local session"
1481 );
1482 crate::remote_git::validate_network_url(
1483 repository.url.as_deref().expect("checked above"),
1484 )?;
1485 for push_url in &repository.push_urls {
1486 crate::remote_git::validate_network_url(push_url)?;
1487 }
1488 ensure!(
1489 repository.git_ref.is_none(),
1490 "git_ref is no longer supported; remove it to start from the remote's default branch"
1491 );
1492 if !destinations.insert(&repository.destination) {
1493 bail!(
1494 "duplicate repository destination {}",
1495 repository.destination
1496 );
1497 }
1498 }
1499 if !destinations.contains(&self.primary) {
1500 bail!("primary repository is not present in the bundle");
1501 }
1502 Ok(())
1503 }
1504}
1505
1506#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1507#[serde(tag = "kind", rename_all = "snake_case")]
1508pub enum PodmanWorkspaceStorage {
1509 PodmanVolume,
1510 HostHelper {
1511 root: String,
1512 helper: Vec<String>,
1513 },
1514 #[default]
1515 ContainerLayer,
1516}
1517
1518#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1519pub struct ContainerTemplate {
1520 pub image: String,
1521 #[serde(default)]
1522 pub pull_policy: ImagePullPolicy,
1523 #[serde(default)]
1524 pub extra_run_args: Vec<String>,
1525 #[serde(default)]
1526 pub workspace_storage: PodmanWorkspaceStorage,
1527 #[serde(default)]
1530 pub build_cache: Option<crate::config::TargetBuildCache>,
1531}
1532
1533impl ImagePullPolicy {
1534 pub fn resolve(self, image: &str) -> Self {
1537 if self != Self::Auto {
1538 return self;
1539 }
1540 if image_is_digest_pinned(image) {
1541 Self::Missing
1542 } else if image_is_remote(image) && image_uses_latest_tag(image) {
1543 Self::Newer
1544 } else {
1545 Self::Missing
1546 }
1547 }
1548
1549 pub fn at_launch(self, image: &str) -> Self {
1554 if self == Self::Auto {
1555 Self::Missing
1556 } else {
1557 self.resolve(image)
1558 }
1559 }
1560
1561 pub fn describe(self, image: &str) -> &'static str {
1566 match self {
1567 Self::Always => "Pull every launch",
1568 Self::Newer => "Pull when the registry is newer",
1569 Self::Missing => "Pull only if missing",
1570 Self::Never => "Never pull",
1571 Self::Auto => match self.resolve(image) {
1572 Self::Newer => "Pull if missing at launch; refresh :latest in background",
1573 _ => "Pull if missing",
1574 },
1575 }
1576 }
1577
1578 pub fn podman_value(self) -> &'static str {
1580 match self {
1581 Self::Always => "always",
1582 Self::Newer => "newer",
1583 Self::Missing => "missing",
1584 Self::Never => "never",
1585 Self::Auto => unreachable!("auto pull policy must resolve"),
1586 }
1587 }
1588}
1589
1590#[derive(Debug, Clone, PartialEq, Eq)]
1596pub enum ImageHost {
1597 LocalPodman,
1598 LocalDocker,
1599 AppleContainer,
1600 SshPodman(SshTarget),
1601 SshDocker(SshTarget),
1602}
1603
1604impl ImageHost {
1605 pub const fn engine(&self) -> &'static str {
1606 match self {
1607 Self::LocalPodman | Self::SshPodman(_) => "podman",
1608 Self::LocalDocker | Self::SshDocker(_) => "docker",
1609 Self::AppleContainer => "container",
1610 }
1611 }
1612
1613 pub fn label(&self) -> String {
1615 match self {
1616 Self::LocalPodman => "local podman".to_owned(),
1617 Self::LocalDocker => "local docker".to_owned(),
1618 Self::AppleContainer => "apple container".to_owned(),
1619 Self::SshPodman(ssh) => format!("podman on {}", ssh.destination),
1620 Self::SshDocker(ssh) => format!("docker on {}", ssh.destination),
1621 }
1622 }
1623
1624 fn command(&self, args: Vec<String>, purpose: String) -> CommandSpec {
1625 match self {
1626 Self::LocalPodman | Self::LocalDocker | Self::AppleContainer => {
1627 CommandSpec::new(args[0].clone(), args[1..].iter().cloned())
1628 }
1629 Self::SshPodman(ssh) | Self::SshDocker(ssh) => ssh_command_owned(ssh, args),
1630 }
1631 .purpose(purpose)
1632 }
1633}
1634
1635#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
1642pub enum RefreshWhen {
1643 WhenAbsent,
1645 Always,
1647}
1648
1649#[derive(Debug, Clone, PartialEq, Eq)]
1652pub struct ImageRefresh {
1653 pub host: ImageHost,
1654 pub image: String,
1655 pub platform: Option<String>,
1656 pub when: RefreshWhen,
1659 pub image_id: CommandSpec,
1662 pub pull: CommandSpec,
1663 pub prune: Option<CommandSpec>,
1666}
1667
1668pub fn image_refresh(
1675 host: ImageHost,
1676 image: &str,
1677 platform: Option<&str>,
1678 pull_policy: ImagePullPolicy,
1679) -> Option<ImageRefresh> {
1680 let when = match pull_policy.resolve(image) {
1681 ImagePullPolicy::Always | ImagePullPolicy::Newer => RefreshWhen::Always,
1682 ImagePullPolicy::Missing => RefreshWhen::WhenAbsent,
1683 ImagePullPolicy::Never => return None,
1684 ImagePullPolicy::Auto => unreachable!("auto pull policy must resolve"),
1685 };
1686 let engine = host.engine();
1687 let apple = matches!(host, ImageHost::AppleContainer);
1691 let mut image_id_args = vec![engine.to_owned(), "image".to_owned(), "inspect".to_owned()];
1692 if !apple {
1693 image_id_args.push("--format".to_owned());
1694 image_id_args.push("{{.Id}}".to_owned());
1695 }
1696 image_id_args.push(image.to_owned());
1697 let image_id = host.command(
1698 image_id_args,
1699 format!("read the cached id of container image {image}"),
1700 );
1701 let mut pull_args = vec![engine.to_owned()];
1702 if apple {
1703 pull_args.push("image".to_owned());
1704 }
1705 pull_args.push("pull".to_owned());
1706 if let Some(platform) = platform.filter(|_| !apple) {
1708 pull_args.push(format!("--platform={platform}"));
1709 }
1710 pull_args.push(image.to_owned());
1711 let pull = host.command(pull_args, format!("refresh container image {image}"));
1712 let prune = (!apple).then(|| {
1713 host.command(
1714 vec![
1715 engine.to_owned(),
1716 "image".to_owned(),
1717 "prune".to_owned(),
1718 "-f".to_owned(),
1719 ],
1720 "remove dangling container images".to_owned(),
1721 )
1722 });
1723 Some(ImageRefresh {
1724 host,
1725 image: image.to_owned(),
1726 platform: platform.map(str::to_owned),
1727 when,
1728 image_id,
1729 pull,
1730 prune,
1731 })
1732}
1733
1734fn image_is_digest_pinned(image: &str) -> bool {
1735 image
1736 .rsplit_once('@')
1737 .is_some_and(|(_, digest)| !digest.is_empty())
1738}
1739
1740fn image_is_remote(image: &str) -> bool {
1741 !image.starts_with("localhost/") && !image.starts_with("local/")
1742}
1743
1744fn image_uses_latest_tag(image: &str) -> bool {
1745 let name = image.split_once('@').map_or(image, |(name, _)| name);
1746 let final_component = name.rsplit('/').next().unwrap_or(name);
1747 !final_component.contains(':') || final_component.ends_with(":latest")
1748}
1749
1750#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1751pub struct SshTarget {
1752 pub destination: String,
1753 #[serde(default)]
1754 pub ssh_args: Vec<String>,
1755}
1756
1757#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1758pub struct AwsTemplate {
1759 pub profile: String,
1760 pub region: String,
1761 pub launch_template: String,
1762 pub launch_template_version: Option<String>,
1763 pub instance_type: Option<String>,
1764 pub ssh: SshTarget,
1765}
1766
1767#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1768#[serde(tag = "kind", rename_all = "snake_case")]
1769pub enum TargetTemplate {
1770 LocalBare,
1771 LocalPodman(ContainerTemplate),
1772 LocalDocker(ContainerTemplate),
1773 AppleContainer(ContainerTemplate),
1774 AwsEc2(AwsTemplate),
1775 SshBare {
1776 ssh: SshTarget,
1777 #[serde(default = "default_ssh_prefix")]
1778 workspace_prefix: String,
1779 },
1780 SshPodman {
1781 ssh: SshTarget,
1782 container: ContainerTemplate,
1783 },
1784 SshDocker {
1785 ssh: SshTarget,
1786 container: ContainerTemplate,
1787 },
1788}
1789
1790fn default_ssh_prefix() -> String {
1791 ".local/share/hel/workspaces".to_owned()
1792}
1793
1794#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1795#[serde(tag = "kind", rename_all = "snake_case")]
1796pub enum PodmanWorkspaceLocator {
1797 #[default]
1798 ContainerLayer,
1799 Volume {
1800 name: String,
1801 },
1802 HostPath {
1803 path: String,
1804 helper: Vec<String>,
1805 resource: String,
1806 },
1807}
1808
1809#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1810#[serde(tag = "kind", rename_all = "snake_case")]
1811pub enum TargetLocator {
1812 LocalBare {
1813 worker_root: String,
1814 },
1815 LocalPodman {
1816 container_id: String,
1817 #[serde(default)]
1818 workspace_storage: PodmanWorkspaceLocator,
1819 #[serde(default, skip_serializing_if = "Option::is_none")]
1823 borrowed_from: Option<String>,
1824 },
1825 LocalDocker {
1826 container_id: String,
1827 #[serde(default, skip_serializing_if = "Option::is_none")]
1831 borrowed_from: Option<String>,
1832 },
1833 AppleContainer {
1834 container_id: String,
1835 #[serde(default, skip_serializing_if = "Option::is_none")]
1839 borrowed_from: Option<String>,
1840 },
1841 AwsEc2 {
1842 profile: String,
1843 region: String,
1844 instance_id: String,
1845 ssh: SshTarget,
1846 workspace: String,
1847 },
1848 SshBare {
1849 ssh: SshTarget,
1850 workspace: String,
1851 #[serde(default, skip_serializing_if = "Option::is_none")]
1853 worker_id: Option<String>,
1854 },
1855 SshPodman {
1856 ssh: SshTarget,
1857 container_id: String,
1858 #[serde(default)]
1859 workspace_storage: PodmanWorkspaceLocator,
1860 #[serde(default, skip_serializing_if = "Option::is_none")]
1864 borrowed_from: Option<String>,
1865 },
1866 SshDocker {
1867 ssh: SshTarget,
1868 container_id: String,
1869 #[serde(default, skip_serializing_if = "Option::is_none")]
1873 borrowed_from: Option<String>,
1874 },
1875}
1876
1877impl TargetTemplate {
1878 pub const fn container_engine(&self) -> Option<&'static str> {
1879 match self {
1880 Self::LocalPodman(_) | Self::SshPodman { .. } => Some("podman"),
1881 Self::LocalDocker(_) | Self::SshDocker { .. } => Some("docker"),
1882 Self::AppleContainer(_) => Some("container"),
1883 _ => None,
1884 }
1885 }
1886
1887 pub fn image_host(&self) -> Option<(ImageHost, &ContainerTemplate)> {
1890 match self {
1891 Self::LocalPodman(container) => Some((ImageHost::LocalPodman, container)),
1892 Self::LocalDocker(container) => Some((ImageHost::LocalDocker, container)),
1893 Self::AppleContainer(container) => Some((ImageHost::AppleContainer, container)),
1894 Self::SshPodman { ssh, container } => {
1895 Some((ImageHost::SshPodman(ssh.clone()), container))
1896 }
1897 Self::SshDocker { ssh, container } => {
1898 Some((ImageHost::SshDocker(ssh.clone()), container))
1899 }
1900 Self::LocalBare | Self::AwsEc2(_) | Self::SshBare { .. } => None,
1901 }
1902 }
1903}
1904
1905impl TargetLocator {
1906 pub const fn kind_name(&self) -> &'static str {
1908 match self {
1909 Self::LocalBare { .. } => "local-bare",
1910 Self::LocalPodman { .. } => "local-podman",
1911 Self::LocalDocker { .. } => "local-docker",
1912 Self::AppleContainer { .. } => "apple-container",
1913 Self::AwsEc2 { .. } => "aws-ec2",
1914 Self::SshBare { .. } => "ssh-bare",
1915 Self::SshPodman { .. } => "ssh-podman",
1916 Self::SshDocker { .. } => "ssh-docker",
1917 }
1918 }
1919
1920 pub const fn container_engine(&self) -> Option<&'static str> {
1921 match self {
1922 Self::LocalPodman { .. } | Self::SshPodman { .. } => Some("podman"),
1923 Self::LocalDocker { .. } | Self::SshDocker { .. } => Some("docker"),
1924 Self::AppleContainer { .. } => Some("container"),
1925 _ => None,
1926 }
1927 }
1928}
1929
1930#[derive(Debug, Clone, PartialEq, Eq)]
1934pub struct TargetRecoveryPlan {
1935 pub exists: CommandSpec,
1936 pub inspect: CommandSpec,
1937 pub start: CommandSpec,
1938 pub session_id: String,
1939}
1940
1941#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1942pub enum TargetRecoveryOutcome {
1943 NotRequired,
1944 Missing,
1945 AlreadyRunning,
1946 Started,
1947}
1948
1949pub fn resource_name(session_id: &str) -> Result<String> {
1950 validate_session_id(session_id)?;
1951 let readable: String = session_id
1952 .chars()
1953 .filter(|character| character.is_ascii_alphanumeric())
1954 .take(12)
1955 .map(|character| character.to_ascii_lowercase())
1956 .collect();
1957 let digest = Sha256::digest(session_id.as_bytes());
1958 Ok(format!(
1959 "mj-{readable}-{:02x}{:02x}{:02x}",
1960 digest[0], digest[1], digest[2]
1961 ))
1962}
1963
1964pub fn move_resource_name(session_id: &str, operation_id: &str) -> Result<String> {
1966 let digest = Sha256::digest(operation_id.as_bytes());
1967 Ok(format!(
1968 "{}-move-{}",
1969 resource_name(session_id)?,
1970 crate::hex::lower_hex(&digest[..8])
1971 ))
1972}
1973
1974pub fn resource_name_belongs_to(name: &str, session_id: &str) -> Result<bool> {
1975 let base = resource_name(session_id)?;
1976 Ok(name == base
1977 || name
1978 .strip_prefix(&format!("{base}-move-"))
1979 .is_some_and(|suffix| {
1980 suffix.len() == 16 && suffix.bytes().all(|b| b.is_ascii_hexdigit())
1981 }))
1982}
1983
1984pub fn podman_workspace_locator(
1985 template: &ContainerTemplate,
1986 session_id: &str,
1987) -> Result<PodmanWorkspaceLocator> {
1988 podman_workspace_locator_named(template, &resource_name(session_id)?)
1989}
1990
1991pub fn podman_workspace_locator_named(
1992 template: &ContainerTemplate,
1993 name: &str,
1994) -> Result<PodmanWorkspaceLocator> {
1995 let resource = format!("{name}-workspace");
1996 match &template.workspace_storage {
1997 PodmanWorkspaceStorage::PodmanVolume => {
1998 Ok(PodmanWorkspaceLocator::Volume { name: resource })
1999 }
2000 PodmanWorkspaceStorage::HostHelper { root, helper } => {
2001 let root = Path::new(root);
2002 ensure!(
2003 root.is_absolute(),
2004 "Podman workspace storage root must be absolute"
2005 );
2006 ensure!(
2007 !helper.is_empty() && helper.iter().all(|argument| !argument.is_empty()),
2008 "Podman workspace storage helper must contain non-empty arguments"
2009 );
2010 Ok(PodmanWorkspaceLocator::HostPath {
2011 path: root.join(&resource).to_string_lossy().into_owned(),
2012 helper: helper.clone(),
2013 resource,
2014 })
2015 }
2016 PodmanWorkspaceStorage::ContainerLayer => Ok(PodmanWorkspaceLocator::ContainerLayer),
2017 }
2018}
2019
2020pub fn container_workspace_root(recorded: Option<&Path>) -> String {
2028 recorded.map_or_else(
2029 || CONTAINER_WORKSPACE.to_owned(),
2030 |path| path.to_string_lossy().into_owned(),
2031 )
2032}
2033
2034pub fn new_container_workspace(session_id: &str) -> Result<PathBuf> {
2036 validate_session_id(session_id)?;
2037 Ok(Path::new(CONTAINER_WORKSPACE).join(session_id))
2038}
2039
2040pub fn aws_workspace(session_id: &str) -> String {
2046 format!(".local/share/hel/workspaces/{session_id}")
2047}
2048
2049pub fn workspace_for(template: &TargetTemplate, session_id: &str) -> Result<String> {
2050 validate_session_id(session_id)?;
2051 match template {
2052 TargetTemplate::LocalBare => bail!("local bare projects use their selected directory"),
2053 TargetTemplate::LocalPodman(_)
2056 | TargetTemplate::LocalDocker(_)
2057 | TargetTemplate::AppleContainer(_)
2058 | TargetTemplate::SshPodman { .. }
2059 | TargetTemplate::SshDocker { .. } => {
2060 bail!("container targets use the session's recorded container workspace")
2061 }
2062 TargetTemplate::AwsEc2(_) => Ok(aws_workspace(session_id)),
2063 TargetTemplate::SshBare {
2064 workspace_prefix, ..
2065 } => {
2066 validate_workspace_prefix(workspace_prefix)?;
2067 let prefix = workspace_prefix
2072 .strip_prefix("~/")
2073 .unwrap_or(workspace_prefix);
2074 Ok(format!("{}/{session_id}", prefix.trim_end_matches('/')))
2075 }
2076 }
2077}
2078
2079pub fn command_on_locator(
2081 locator: &TargetLocator,
2082 session_id: &str,
2083 args: Vec<String>,
2084 purpose: impl Into<String>,
2085) -> Result<CommandSpec> {
2086 verify_locator(locator, session_id)?;
2087 if args.is_empty() {
2088 bail!("target command must not be empty");
2089 }
2090 Ok(locator_command(locator, args).purpose(purpose))
2091}
2092
2093pub fn locator_command(locator: &TargetLocator, args: Vec<String>) -> CommandSpec {
2097 match locator {
2098 TargetLocator::LocalBare { .. } => {
2099 let mut args = args.into_iter();
2100 let program = args.next().expect("target command must not be empty");
2101 CommandSpec::new(program, args)
2102 }
2103 TargetLocator::LocalPodman { container_id, .. }
2104 | TargetLocator::LocalDocker { container_id, .. }
2105 | TargetLocator::AppleContainer { container_id, .. } => container_exec(
2106 locator.container_engine().expect("local container"),
2107 container_id,
2108 args,
2109 ),
2110 TargetLocator::AwsEc2 { ssh, .. } | TargetLocator::SshBare { ssh, .. } => {
2111 ssh_command_owned(ssh, args)
2112 }
2113 TargetLocator::SshPodman {
2114 ssh, container_id, ..
2115 }
2116 | TargetLocator::SshDocker {
2117 ssh, container_id, ..
2118 } => {
2119 let mut remote = vec![
2120 locator
2121 .container_engine()
2122 .expect("remote container")
2123 .to_owned(),
2124 "exec".to_owned(),
2125 "-i".to_owned(),
2126 container_id.to_owned(),
2127 ];
2128 remote.extend(args);
2129 ssh_command_owned(ssh, remote)
2130 }
2131 }
2132}
2133pub fn worker_root(locator: &TargetLocator, session_id: &str) -> Result<String> {
2134 verify_locator(locator, session_id)?;
2135 Ok(match locator {
2136 TargetLocator::LocalBare { worker_root } => worker_root.clone(),
2137 TargetLocator::LocalPodman { .. }
2138 | TargetLocator::LocalDocker { .. }
2139 | TargetLocator::AppleContainer { .. }
2140 | TargetLocator::SshPodman { .. }
2141 | TargetLocator::SshDocker { .. } => format!("/var/lib/hel/workers/{session_id}"),
2142 TargetLocator::AwsEc2 { .. } => format!(".local/share/hel/workers/{session_id}"),
2143 TargetLocator::SshBare { worker_id, .. } => format!(
2144 ".local/share/hel/workers/{}",
2145 worker_id.as_deref().unwrap_or(session_id)
2146 ),
2147 })
2148}
2149mod convert;
2150pub use convert::{
2151 RecordedTarget, StoredTarget, TargetConversionError, locator_needs_connection,
2152 ssh_args_with_identity,
2153};
2154
2155mod ssh;
2156pub use ssh::*;
2157
2158pub fn container_exec(
2159 engine: &str,
2160 container_id: &str,
2161 args: impl IntoIterator<Item = impl Into<String>>,
2162) -> CommandSpec {
2163 let mut command_args = vec!["exec".to_owned(), "-i".to_owned(), container_id.to_owned()];
2164 command_args.extend(args.into_iter().map(Into::into));
2165 CommandSpec::new(engine, command_args)
2166}
2167
2168#[cfg(all(test, unix))]
2169mod executor_tests {
2170 use std::fs;
2171
2172 use super::*;
2173
2174 #[cfg(target_os = "linux")]
2175 #[test]
2176 fn successful_commands_stop_descendants_that_closed_their_pipes() {
2177 for streamed in [false, true] {
2178 let temp = tempfile::tempdir().unwrap();
2179 let pid_file = temp.path().join("descendant");
2180 let command = CommandSpec::new("sh", [
2181 "-c".to_owned(),
2182 "cat >/dev/null; head -c 131072 /dev/zero; sleep 60 </dev/null >/dev/null 2>&1 & echo $! > \"$1\"".to_owned(),
2183 "owned-descendant".to_owned(), pid_file.display().to_string(),
2184 ]);
2185 let executor = CancellableProcessExecutor::with_timeout(Duration::from_secs(5));
2186 let output = if streamed {
2187 executor
2188 .execute_with_stdin(&command, &mut std::io::Cursor::new(vec![b'x'; 256 * 1024]))
2189 } else {
2190 executor.execute(&command)
2191 }
2192 .unwrap();
2193 assert_eq!(output.stdout.len(), 131072);
2194 let pid: i32 = fs::read_to_string(pid_file)
2195 .unwrap()
2196 .trim()
2197 .parse()
2198 .unwrap();
2199 let deadline = Instant::now() + Duration::from_secs(2);
2200 loop {
2201 let state = fs::read_to_string(format!("/proc/{pid}/stat")).ok();
2202 if state.as_ref().is_none_or(|state| {
2203 state
2204 .rsplit_once(") ")
2205 .is_some_and(|(_, fields)| fields.starts_with('Z'))
2206 }) {
2207 break;
2208 }
2209 if Instant::now() >= deadline {
2210 unsafe {
2212 libc::kill(pid, libc::SIGKILL);
2213 }
2214 panic!(
2215 "successful command left descendant {pid} running (streamed={streamed})"
2216 );
2217 }
2218 std::thread::sleep(Duration::from_millis(10));
2219 }
2220 }
2221 }
2222
2223 #[cfg(target_os = "linux")]
2226 #[test]
2227 fn commands_complete_at_leader_exit_when_a_descendant_holds_the_pipes() {
2228 for streamed in [false, true] {
2229 let temp = tempfile::tempdir().unwrap();
2230 let pid_file = temp.path().join("descendant");
2231 let command = CommandSpec::new(
2232 "sh",
2233 [
2234 "-c".to_owned(),
2235 "sleep 300 & echo $! > \"$1\"; echo hi; exit 3".to_owned(),
2236 "held-pipes".to_owned(),
2237 pid_file.display().to_string(),
2238 ],
2239 );
2240 let executor = CancellableProcessExecutor::with_timeout(Duration::from_secs(30));
2241 let started = Instant::now();
2242 let output = if streamed {
2243 executor.execute_with_stdin(&command, &mut std::io::Cursor::new(b"input".to_vec()))
2244 } else {
2245 executor.execute(&command)
2246 }
2247 .unwrap();
2248 assert!(
2249 started.elapsed() < Duration::from_secs(5),
2250 "streamed={streamed} took {:?}",
2251 started.elapsed()
2252 );
2253 assert_eq!(output.status, 3);
2254 assert_eq!(output.stdout, b"hi\n");
2255 let pid: i32 = fs::read_to_string(pid_file)
2256 .unwrap()
2257 .trim()
2258 .parse()
2259 .unwrap();
2260 let deadline = Instant::now() + Duration::from_secs(2);
2261 loop {
2262 let state = fs::read_to_string(format!("/proc/{pid}/stat")).ok();
2263 if state.as_ref().is_none_or(|state| {
2264 state
2265 .rsplit_once(") ")
2266 .is_some_and(|(_, fields)| fields.starts_with('Z'))
2267 }) {
2268 break;
2269 }
2270 if Instant::now() >= deadline {
2271 unsafe {
2273 libc::kill(pid, libc::SIGKILL);
2274 }
2275 panic!("completed command left descendant {pid} (streamed={streamed})");
2276 }
2277 std::thread::sleep(Duration::from_millis(10));
2278 }
2279 }
2280 }
2281
2282 #[test]
2283 fn streamed_deadline_survives_leader_exit_and_inherited_pipes() {
2284 let command = CommandSpec::new(
2285 "sh",
2286 [
2287 "-c",
2288 "head -c 131072 /dev/zero; cat >/dev/null; (trap '' TERM; sleep 60) & exit 0",
2289 ],
2290 );
2291 let mut input = std::io::Cursor::new(vec![b'x'; 256 * 1024]);
2292 let started = Instant::now();
2293 let error = CancellableProcessExecutor::with_timeout(Duration::from_millis(300))
2294 .execute_with_stdin(&command, &mut input)
2295 .unwrap_err();
2296 assert!(error.to_string().contains("cancelled"), "{error:#}");
2297 assert!(started.elapsed() < Duration::from_secs(5));
2298 }
2299
2300 fn flaky_ssh_script(directory: &Path) -> CommandSpec {
2304 let counter = directory.join("attempts");
2305 let script = format!(
2306 "count=$(cat {counter} 2>/dev/null || echo 0)\n\
2307 echo $((count + 1)) > {counter}\n\
2308 if [ \"$count\" -eq 0 ]; then\n\
2309 echo 'kex_exchange_identification: Connection closed by 10.0.0.1 port 22' >&2\n\
2310 exit 255\n\
2311 fi\n\
2312 echo connected\n",
2313 counter = counter.display()
2314 );
2315 CommandSpec::new("sh", ["-c".to_owned(), script])
2316 .ssh_destination("build@10.0.0.1")
2317 .purpose("run the flaky SSH fixture")
2318 }
2319
2320 fn attempts(directory: &Path) -> u32 {
2321 fs::read_to_string(directory.join("attempts"))
2322 .expect("the fixture records its attempts")
2323 .trim()
2324 .parse()
2325 .expect("attempt count is a number")
2326 }
2327
2328 #[derive(Default)]
2333 struct MasterKilledOnce {
2334 running: std::cell::RefCell<BTreeSet<String>>,
2335 openers: std::cell::Cell<usize>,
2336 sessions: std::cell::RefCell<Vec<Vec<String>>>,
2337 }
2338
2339 impl CommandExecutor for MasterKilledOnce {
2340 fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
2341 let reply = |status: i32, stderr: &str| CommandOutput {
2342 status,
2343 stdout: Vec::new(),
2344 stderr: stderr.as_bytes().to_vec(),
2345 };
2346 if command.ssh_session.is_some() {
2347 return with_ssh_admission(command, self, &|| false, |spawned| {
2349 self.sessions.borrow_mut().push(spawned.args.clone());
2350 if self.sessions.borrow().len() == 1 {
2351 self.running.borrow_mut().clear();
2352 return Ok(reply(255, "Connection closed by UNKNOWN port 65535"));
2353 }
2354 Ok(reply(0, ""))
2355 });
2356 }
2357 let socket = command
2358 .args
2359 .iter()
2360 .find_map(|arg| arg.strip_prefix("ControlPath="))
2361 .expect("a master command names its socket")
2362 .to_owned();
2363 if command.args.windows(2).any(|pair| pair == ["-O", "check"]) {
2364 return Ok(reply(
2365 if self.running.borrow().contains(&socket) {
2366 0
2367 } else {
2368 255
2369 },
2370 "",
2371 ));
2372 }
2373 assert!(command.args.contains(&"ControlMaster=yes".to_owned()));
2374 self.openers.set(self.openers.get() + 1);
2375 self.running.borrow_mut().insert(socket);
2376 Ok(reply(0, ""))
2377 }
2378 }
2379
2380 #[test]
2381 fn a_session_whose_master_died_is_retried_on_a_reopened_master() {
2382 let _guard = ssh::SHARING_TEST_LOCK
2383 .lock()
2384 .unwrap_or_else(std::sync::PoisonError::into_inner);
2385 set_ssh_retry_backoff_for_test(Some(Duration::from_millis(5)));
2386 let socket_dir = tempfile::tempdir_in("/tmp").expect("short socket directory");
2387 set_ssh_connection_sharing_for_test(Some(SshSharingForTest::Directory(
2388 socket_dir.path().to_path_buf(),
2389 )));
2390 let ssh = SshTarget {
2391 destination: "master-killed-once-host".to_owned(),
2392 ssh_args: Vec::new(),
2393 };
2394 let executor = MasterKilledOnce::default();
2395 let output = executor.execute(&ssh_command(&ssh, ["true"]));
2396 set_ssh_connection_sharing_for_test(None);
2397 set_ssh_retry_backoff_for_test(None);
2398
2399 assert_eq!(output.expect("the retry succeeds").status, 0);
2400 let sessions = executor.sessions.borrow();
2401 assert_eq!(sessions.len(), 2);
2402 assert_eq!(
2403 executor.openers.get(),
2404 2,
2405 "the retry reopens the master instead of trusting the earlier check"
2406 );
2407 for args in sessions.iter() {
2408 assert_eq!(args[..6][5], "ProxyCommand=false", "{args:?}");
2409 }
2410 }
2411
2412 #[test]
2413 fn a_transport_rejected_ssh_command_is_retried_once_and_then_succeeds() {
2414 set_ssh_retry_backoff_for_test(Some(Duration::from_millis(5)));
2415 let directory = tempfile::tempdir().expect("temp dir");
2416 let command = flaky_ssh_script(directory.path());
2417
2418 let output = ProcessExecutor
2419 .execute(&command)
2420 .expect("the retry must reach the successful attempt");
2421
2422 assert_eq!(output.status, 0);
2423 assert_eq!(String::from_utf8_lossy(&output.stdout).trim(), "connected");
2424 assert_eq!(attempts(directory.path()), 2);
2425 set_ssh_retry_backoff_for_test(None);
2426 }
2427
2428 #[test]
2429 fn an_untagged_command_is_not_retried_after_the_same_failure() {
2430 set_ssh_retry_backoff_for_test(Some(Duration::from_millis(5)));
2431 let directory = tempfile::tempdir().expect("temp dir");
2432 let mut command = flaky_ssh_script(directory.path());
2433 command.ssh_destination = None;
2434
2435 let output = ProcessExecutor.execute(&command).expect("runs once");
2436
2437 assert_eq!(output.status, 255);
2438 assert_eq!(attempts(directory.path()), 1);
2439 set_ssh_retry_backoff_for_test(None);
2440 }
2441
2442 #[test]
2443 fn the_cancellable_executor_also_retries_a_transport_rejection() {
2444 set_ssh_retry_backoff_for_test(Some(Duration::from_millis(5)));
2445 let directory = tempfile::tempdir().expect("temp dir");
2446 let command = flaky_ssh_script(directory.path());
2447
2448 let output = CancellableProcessExecutor::new(Arc::new(AtomicBool::new(false)))
2449 .execute(&command)
2450 .expect("the retry must reach the successful attempt");
2451
2452 assert_eq!(output.status, 0);
2453 assert_eq!(attempts(directory.path()), 2);
2454 set_ssh_retry_backoff_for_test(None);
2455 }
2456}