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 execute_cleanup(&self, command: &CommandSpec) -> Result<CommandOutput> {
522 self.execute(command)
523 }
524
525 fn cancellation_requested(&self) -> bool {
529 false
530 }
531
532 fn stage_started(&self, _stage: ProvisionStage) {}
536
537 fn stage_finished(&self, _stage: ProvisionStage) {}
540
541 fn notify_notice(&self, _notice: &str) {}
544
545 fn before_move_source_stop(
547 &self,
548 ) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<()>> + Send + '_>> {
549 Box::pin(async { Ok(()) })
550 }
551
552 fn reserve_move_destination(&self) {}
555
556 fn begin_resumable_move_work(&self) -> Result<()> {
558 Ok(())
559 }
560 fn end_resumable_move_work(&self) -> Result<()> {
561 Ok(())
562 }
563
564 fn execute_with_stdin(
565 &self,
566 _command: &CommandSpec,
567 _input: &mut (dyn Read + Send),
568 ) -> Result<CommandOutput> {
569 bail!("this command executor does not support streamed stdin")
570 }
571}
572
573pub struct ProvisionStageGuard<'a, E: CommandExecutor + ?Sized> {
577 executor: &'a E,
578 stage: ProvisionStage,
579}
580
581impl<'a, E: CommandExecutor + ?Sized> ProvisionStageGuard<'a, E> {
582 pub fn new(executor: &'a E, stage: ProvisionStage) -> Self {
583 executor.stage_started(stage);
584 Self { executor, stage }
585 }
586}
587
588impl<E: CommandExecutor + ?Sized> Drop for ProvisionStageGuard<'_, E> {
589 fn drop(&mut self) {
590 self.executor.stage_finished(self.stage);
591 }
592}
593
594pub struct ProcessExecutor;
595
596fn with_ssh_admission(
612 command: &CommandSpec,
613 executor: &dyn CommandExecutor,
614 is_cancelled: &dyn Fn() -> bool,
615 mut run: impl FnMut(&CommandSpec) -> Result<CommandOutput>,
616) -> Result<CommandOutput> {
617 let Some(destination) = command.ssh_destination.as_deref() else {
618 return run(command);
619 };
620 for attempt in 1..=SSH_RETRY_ATTEMPTS {
621 let session = command.open_ssh_session(executor)?;
622 let output = {
623 let _permit = SshAdmission::acquire_unless(destination, is_cancelled)?;
624 run(session.command())?
625 };
626 let refusal = ssh_refusal(output.status, &String::from_utf8_lossy(&output.stderr));
627 if refusal == Some(SshRefusal::BeforeAuthentication)
630 && let Some(lease) = session.lease()
631 {
632 lease.invalidate();
633 }
634 drop(session);
635 let Some(refusal) = refusal else {
636 return Ok(output);
637 };
638 let stderr = String::from_utf8_lossy(&output.stderr);
639 if attempt == SSH_RETRY_ATTEMPTS {
640 refusal.log_exhausted(destination, &command.purpose, stderr.trim());
641 return Ok(output);
642 }
643 let delay = ssh_retry_delay(attempt);
644 refusal.log_retry(destination, &command.purpose, attempt, delay, stderr.trim());
645 if !sleep_unless_cancelled(delay, is_cancelled) {
646 bail!("operation cancelled while {}", command.purpose);
647 }
648 }
649 unreachable!("the final attempt always returns");
650}
651
652fn sleep_unless_cancelled(delay: Duration, is_cancelled: &dyn Fn() -> bool) -> bool {
655 let deadline = Instant::now() + delay;
656 loop {
657 if is_cancelled() {
658 return false;
659 }
660 let remaining = deadline.saturating_duration_since(Instant::now());
661 if remaining.is_zero() {
662 return true;
663 }
664 std::thread::sleep(remaining.min(Duration::from_millis(50)));
665 }
666}
667
668pub fn trace_command_duration(command: &CommandSpec, started: Instant, status: i32) {
671 tracing::debug!(
672 purpose = command.purpose.as_str(),
673 program = command.program.as_str(),
674 status,
675 elapsed_ms = started.elapsed().as_millis() as u64,
676 "target command finished"
677 );
678}
679
680impl ProcessExecutor {
681 fn run_once(&self, command: &CommandSpec) -> Result<CommandOutput> {
683 if let Some(input) = &command.sensitive_stdin {
684 let mut input = std::io::Cursor::new(input.0.as_slice());
685 return stream_command_with_stdin(
687 cancellable_command(command),
688 command,
689 &mut input,
690 &|| false,
691 );
692 }
693 let started = Instant::now();
694 let output = configured_command(command)
695 .stdin(Stdio::null())
696 .output()
697 .with_context(|| format!("run {} for {}", command.program, command.purpose))?;
698 let status = output.status.code().unwrap_or(-1);
699 trace_command_duration(command, started, status);
700 Ok(CommandOutput {
701 status,
702 stdout: output.stdout,
703 stderr: output.stderr,
704 })
705 }
706}
707
708impl CommandExecutor for ProcessExecutor {
713 fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
714 crate::runtime::off_async_worker(|| {
715 let _running = BlockingOperation::command(command);
716 with_ssh_admission(command, self, &|| false, |command| self.run_once(command))
717 })
718 }
719
720 fn execute_with_stdin(
721 &self,
722 command: &CommandSpec,
723 input: &mut (dyn Read + Send),
724 ) -> Result<CommandOutput> {
725 crate::runtime::off_async_worker(|| {
726 let _running = BlockingOperation::command(command);
727 let session = command.open_ssh_session(self)?;
730 let _permit = command
731 .ssh_destination
732 .as_deref()
733 .map(SshAdmission::acquire);
734 let command = session.command();
735 let process = cancellable_command(command);
736 stream_command_with_stdin(process, command, input, &|| false)
739 })
740 }
741}
742
743fn stream_command_with_stdin(
752 mut process: Command,
753 command: &CommandSpec,
754 input: &mut (dyn Read + Send),
755 is_cancelled: &(dyn Fn() -> bool + Sync),
756) -> Result<CommandOutput> {
757 let started = Instant::now();
758 if is_cancelled() {
759 bail!("operation cancelled");
760 }
761 let mut child = process
762 .stdin(Stdio::piped())
763 .stdout(Stdio::piped())
764 .stderr(Stdio::piped())
765 .spawn()
766 .with_context(|| format!("run {} for {}", command.program, command.purpose))?;
767 let group =
768 (!command.detaches).then(|| crate::subprocess::ProcessGroupGuard::new(Some(child.id())));
769 let stdin = child
770 .stdin
771 .take()
772 .context("streamed command stdin missing")?;
773 let stdout = child
774 .stdout
775 .take()
776 .context("streamed command stdout missing")?;
777 let stderr = child
778 .stderr
779 .take()
780 .context("streamed command stderr missing")?;
781 let stdout_reader = PipeCollector::spawn(stdout);
784 let stderr_reader = PipeCollector::spawn(stderr);
785 let process_result = std::thread::scope(|scope| -> Result<_> {
786 let input_writer = scope.spawn(move || -> Result<()> {
790 let mut stdin = stdin;
795 let mut buffer = [0_u8; 64 * 1024];
796 loop {
797 if is_cancelled() {
801 bail!("operation cancelled");
802 }
803 let count = input.read(&mut buffer).context("read command input")?;
804 if count == 0 {
805 break;
806 }
807 stdin
808 .write_all(&buffer[..count])
809 .context("stream command input")?;
810 }
811 stdin.flush().context("flush command input")
812 });
813 let mut status = None;
814 let mut exited_at = None;
815 let mut group_killed = false;
816 let status = loop {
817 if is_cancelled() {
818 terminate_cancellable_child(&mut child);
819 if let Err(error) = input_writer.join() {
820 tracing::warn!(
821 purpose = command.purpose.as_str(),
822 "streamed command input writer panicked while cancelling: {error:?}"
823 );
824 }
825 bail!("operation cancelled while {}", command.purpose);
826 }
827 match if status.is_some() {
828 Ok(status)
829 } else {
830 child.try_wait()
831 } {
832 Ok(observed) => status = observed,
833 Err(error) => {
834 terminate_cancellable_child(&mut child);
835 if let Err(join_error) = input_writer.join() {
836 tracing::warn!(
837 purpose = command.purpose.as_str(),
838 "streamed command input writer panicked while waiting: {join_error:?}"
839 );
840 }
841 return Err(error).with_context(|| format!("wait for {}", command.purpose));
842 }
843 }
844 if let Some(status) = status {
849 let exited_at = *exited_at.get_or_insert_with(Instant::now);
850 let drained = (stdout_reader.is_finished() && stderr_reader.is_finished())
851 || exited_at.elapsed() >= IO_DRAIN_TIMEOUT;
852 if drained && !input_writer.is_finished() && !group_killed {
853 group_killed = true;
854 if let Some(group) = &group {
855 group.kill();
856 }
857 }
858 if drained && input_writer.is_finished() {
859 break status;
860 }
861 }
862 std::thread::sleep(Duration::from_millis(25));
863 };
864 let input_result = input_writer
865 .join()
866 .map_err(|_| anyhow::anyhow!("streamed command input writer panicked"))?;
867 Ok((status, input_result))
868 });
869 let deadline = Instant::now();
870 let stdout = stdout_reader.finish("stdout", deadline)?;
871 let stderr = stderr_reader.finish("stderr", deadline)?;
872 let (status, input_result) = process_result?;
873 drop(group);
874 if status.success() {
875 input_result?;
879 }
880 let status = status.code().unwrap_or(-1);
881 trace_command_duration(command, started, status);
882 Ok(CommandOutput {
883 status,
884 stdout,
885 stderr,
886 })
887}
888
889#[derive(Clone)]
890pub struct CancellableProcessExecutor {
891 cancelled: Arc<AtomicBool>,
892 deadline: Option<Instant>,
893}
894
895pub struct ProcessCancellationGuard(Arc<AtomicBool>);
897
898impl Drop for ProcessCancellationGuard {
899 fn drop(&mut self) {
900 self.0.store(true, Ordering::Release);
901 }
902}
903
904impl CancellableProcessExecutor {
905 pub fn cancel_on_drop(&self) -> ProcessCancellationGuard {
906 ProcessCancellationGuard(self.cancelled.clone())
907 }
908
909 pub fn new(cancelled: Arc<AtomicBool>) -> Self {
910 Self {
911 cancelled,
912 deadline: None,
913 }
914 }
915
916 pub fn is_cancelled(&self) -> bool {
917 self.cancelled.load(Ordering::Acquire)
918 || self
919 .deadline
920 .is_some_and(|deadline| Instant::now() >= deadline)
921 }
922
923 pub fn with_timeout(timeout: Duration) -> Self {
924 Self {
925 cancelled: Arc::new(AtomicBool::new(false)),
926 deadline: Some(Instant::now() + timeout),
927 }
928 }
929
930 pub fn with_deadline(mut self, timeout: Duration) -> Self {
933 self.deadline = Some(Instant::now() + timeout);
934 self
935 }
936
937 fn check_cancelled(&self) -> Result<()> {
938 if self.is_cancelled() {
939 bail!("operation cancelled");
940 }
941 Ok(())
942 }
943}
944
945fn configured_command(command: &CommandSpec) -> Command {
946 let mut process = Command::new(&command.program);
947 if command.clear_env {
948 process.env_clear();
949 }
950 if let Some(cwd) = &command.cwd {
951 process.current_dir(cwd);
952 }
953 process.args(&command.args).envs(&command.env);
954 process
955}
956
957fn cancellable_command(command: &CommandSpec) -> Command {
958 #[cfg(unix)]
959 let mut process = configured_command(command);
960 #[cfg(not(unix))]
961 let process = configured_command(command);
962 #[cfg(unix)]
963 {
964 use std::os::unix::process::CommandExt as _;
965 process.process_group(0);
966 }
967 process
968}
969
970const IO_DRAIN_TIMEOUT: Duration = Duration::from_secs(2);
976
977trait PollablePipe: Read + Send + 'static {
980 fn readable(&self, timeout: Duration) -> bool;
981}
982
983macro_rules! pollable_pipe {
984 ($pipe:ty) => {
985 impl PollablePipe for $pipe {
986 #[cfg(unix)]
987 fn readable(&self, timeout: Duration) -> bool {
988 use std::os::fd::AsRawFd as _;
989 let mut poll = libc::pollfd {
990 fd: self.as_raw_fd(),
991 events: libc::POLLIN,
992 revents: 0,
993 };
994 let millis = i32::try_from(timeout.as_millis()).unwrap_or(i32::MAX);
995 unsafe { libc::poll(&raw mut poll, 1, millis) > 0 }
997 }
998
999 #[cfg(not(unix))]
1001 fn readable(&self, _timeout: Duration) -> bool {
1002 true
1003 }
1004 }
1005 };
1006}
1007
1008pollable_pipe!(std::process::ChildStdout);
1009pollable_pipe!(std::process::ChildStderr);
1010
1011struct PipeCollector {
1015 bytes: Arc<std::sync::Mutex<Vec<u8>>>,
1016 stop: Arc<AtomicBool>,
1017 thread: Option<std::thread::JoinHandle<std::io::Result<()>>>,
1018}
1019
1020impl PipeCollector {
1021 fn spawn(mut pipe: impl PollablePipe) -> Self {
1022 let bytes = Arc::new(std::sync::Mutex::new(Vec::new()));
1023 let stop = Arc::new(AtomicBool::new(false));
1024 let (collected, stopped) = (bytes.clone(), stop.clone());
1025 let thread = std::thread::spawn(move || {
1026 let mut chunk = [0_u8; 8192];
1027 while !stopped.load(Ordering::Acquire) {
1028 if !pipe.readable(Duration::from_millis(25)) {
1029 continue;
1030 }
1031 match pipe.read(&mut chunk) {
1032 Ok(0) => break,
1033 Ok(count) => collected
1034 .lock()
1035 .unwrap_or_else(std::sync::PoisonError::into_inner)
1036 .extend_from_slice(&chunk[..count]),
1037 Err(error) if error.kind() == std::io::ErrorKind::Interrupted => {}
1038 Err(error) => return Err(error),
1039 }
1040 }
1041 Ok(())
1042 });
1043 Self {
1044 bytes,
1045 stop,
1046 thread: Some(thread),
1047 }
1048 }
1049
1050 fn is_finished(&self) -> bool {
1051 self.thread
1052 .as_ref()
1053 .is_none_or(std::thread::JoinHandle::is_finished)
1054 }
1055
1056 fn finish(mut self, stream: &str, deadline: Instant) -> Result<Vec<u8>> {
1059 while !self.is_finished() && Instant::now() < deadline {
1060 std::thread::sleep(Duration::from_millis(5));
1061 }
1062 self.stop.store(true, Ordering::Release);
1063 self.thread
1064 .take()
1065 .context("command reader already joined")?
1066 .join()
1067 .map_err(|_| anyhow::anyhow!("command {stream} reader panicked"))?
1068 .with_context(|| format!("read command {stream}"))?;
1069 let mut bytes = self
1070 .bytes
1071 .lock()
1072 .unwrap_or_else(std::sync::PoisonError::into_inner);
1073 Ok(std::mem::take(&mut bytes))
1074 }
1075}
1076
1077impl Drop for PipeCollector {
1078 fn drop(&mut self) {
1079 self.stop.store(true, Ordering::Release);
1080 }
1081}
1082
1083fn terminate_cancellable_child(child: &mut std::process::Child) {
1084 #[cfg(unix)]
1085 if let Err(error) = crate::subprocess::signal_process_group(child.id() as i32, libc::SIGKILL) {
1090 tracing::warn!(pid = child.id(), %error, "could not terminate cancelled command process group");
1091 }
1092 #[cfg(not(unix))]
1093 if let Err(error) = child.kill() {
1094 tracing::warn!(pid = child.id(), %error, "could not terminate cancelled command");
1095 }
1096 if let Err(error) = child.wait() {
1097 tracing::warn!(pid = child.id(), %error, "could not reap cancelled command");
1098 }
1099}
1100
1101impl CancellableProcessExecutor {
1102 fn run_once(&self, command: &CommandSpec) -> Result<CommandOutput> {
1104 if let Some(input) = &command.sensitive_stdin {
1105 let mut input = std::io::Cursor::new(input.0.as_slice());
1106 return stream_command_with_stdin(
1108 cancellable_command(command),
1109 command,
1110 &mut input,
1111 &|| self.is_cancelled(),
1112 );
1113 }
1114 let started = Instant::now();
1115 self.check_cancelled()?;
1116 let mut child = cancellable_command(command)
1117 .stdin(Stdio::null())
1118 .stdout(Stdio::piped())
1119 .stderr(Stdio::piped())
1120 .spawn()
1121 .with_context(|| format!("run {} for {}", command.program, command.purpose))?;
1122 let group = (!command.detaches)
1123 .then(|| crate::subprocess::ProcessGroupGuard::new(Some(child.id())));
1124 let stdout = child.stdout.take().context("command stdout missing")?;
1125 let stderr = child.stderr.take().context("command stderr missing")?;
1126 let stdout_reader = PipeCollector::spawn(stdout);
1127 let stderr_reader = PipeCollector::spawn(stderr);
1128 let mut status = None;
1129 let mut exited_at = None;
1130 let status = loop {
1131 if self.is_cancelled() {
1132 terminate_cancellable_child(&mut child);
1133 let deadline = Instant::now() + IO_DRAIN_TIMEOUT;
1134 for (stream, reader) in [("stdout", stdout_reader), ("stderr", stderr_reader)] {
1135 if let Err(error) = reader.finish(stream, deadline) {
1136 tracing::warn!(stream, %error, "cancelled command reader failed");
1137 }
1138 }
1139 bail!("operation cancelled while {}", command.purpose);
1140 }
1141 if status.is_none() {
1142 status = child
1143 .try_wait()
1144 .with_context(|| format!("wait for {}", command.purpose))?;
1145 }
1146 if let Some(status) = status {
1149 let exited_at = *exited_at.get_or_insert_with(Instant::now);
1150 if (stdout_reader.is_finished() && stderr_reader.is_finished())
1151 || exited_at.elapsed() >= IO_DRAIN_TIMEOUT
1152 {
1153 break status;
1154 }
1155 }
1156 std::thread::sleep(Duration::from_millis(25));
1157 };
1158 let deadline = Instant::now();
1159 let stdout = stdout_reader.finish("stdout", deadline)?;
1160 let stderr = stderr_reader.finish("stderr", deadline)?;
1161 let status = status.code().unwrap_or(-1);
1162 drop(group);
1163 trace_command_duration(command, started, status);
1164 Ok(CommandOutput {
1165 status,
1166 stdout,
1167 stderr,
1168 })
1169 }
1170}
1171
1172impl CommandExecutor for CancellableProcessExecutor {
1173 fn execute_cleanup(&self, command: &CommandSpec) -> Result<CommandOutput> {
1174 self.clone()
1175 .with_deadline(Duration::from_secs(15))
1176 .execute(command)
1177 }
1178
1179 fn cancellation_requested(&self) -> bool {
1180 self.is_cancelled()
1181 }
1182
1183 fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
1184 crate::runtime::off_async_worker(|| {
1185 let _running = BlockingOperation::command(command);
1186 with_ssh_admission(command, self, &|| self.is_cancelled(), |command| {
1187 self.run_once(command)
1188 })
1189 })
1190 }
1191
1192 fn execute_with_stdin(
1193 &self,
1194 command: &CommandSpec,
1195 input: &mut (dyn Read + Send),
1196 ) -> Result<CommandOutput> {
1197 crate::runtime::off_async_worker(|| {
1198 let _running = BlockingOperation::command(command);
1199 let session = command.open_ssh_session(self)?;
1202 let _permit = command
1203 .ssh_destination
1204 .as_deref()
1205 .map(|destination| {
1206 SshAdmission::acquire_unless(destination, &|| self.is_cancelled())
1207 })
1208 .transpose()?;
1209 let command = session.command();
1210 stream_command_with_stdin(cancellable_command(command), command, input, &|| {
1214 self.is_cancelled()
1215 })
1216 })
1217 }
1218}
1219
1220#[derive(Debug, Clone, PartialEq, Eq)]
1224pub struct CommandTimedOut {
1225 pub program: String,
1226 pub purpose: String,
1227 pub timeout: Duration,
1228}
1229
1230impl std::fmt::Display for CommandTimedOut {
1231 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1232 write!(
1233 formatter,
1234 "`{}` did not answer within {} seconds while trying to {}",
1235 self.program,
1236 self.timeout.as_secs(),
1237 self.purpose
1238 )
1239 }
1240}
1241
1242impl std::error::Error for CommandTimedOut {}
1243
1244#[derive(Debug, Clone, Copy)]
1253pub struct BoundedProcessExecutor {
1254 timeout: Duration,
1255}
1256
1257impl BoundedProcessExecutor {
1258 pub const fn new(timeout: Duration) -> Self {
1259 Self { timeout }
1260 }
1261}
1262
1263impl CommandExecutor for BoundedProcessExecutor {
1264 fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
1265 let executor = CancellableProcessExecutor::with_timeout(self.timeout);
1266 executor.execute(command).map_err(|error| {
1267 if executor.is_cancelled() {
1268 anyhow::Error::new(CommandTimedOut {
1269 program: command.program.clone(),
1270 purpose: command.purpose.clone(),
1271 timeout: self.timeout,
1272 })
1273 } else {
1274 error
1275 }
1276 })
1277 }
1278
1279 fn execute_with_stdin(
1280 &self,
1281 command: &CommandSpec,
1282 input: &mut (dyn Read + Send),
1283 ) -> Result<CommandOutput> {
1284 CancellableProcessExecutor::with_timeout(self.timeout).execute_with_stdin(command, input)
1285 }
1286}
1287
1288#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1289pub struct CommandPlan {
1290 pub description: String,
1291 pub commands: Vec<CommandSpec>,
1292}
1293
1294impl CommandPlan {
1295 pub fn provide_target_environment_secret(
1299 &mut self,
1300 target: &TargetTemplate,
1301 name: &str,
1302 value: &str,
1303 ) -> Result<()> {
1304 ensure!(
1305 !name.is_empty()
1306 && name.bytes().enumerate().all(|(index, byte)| byte == b'_'
1307 || byte.is_ascii_alphabetic()
1308 || (index > 0 && byte.is_ascii_digit())),
1309 "invalid secret environment variable name"
1310 );
1311 ensure!(
1312 !value.as_bytes().contains(&b'\n') && !value.as_bytes().contains(&b'\r'),
1313 "secret environment value cannot contain a newline"
1314 );
1315 let command = self
1316 .commands
1317 .iter_mut()
1318 .find(|command| command.creates_target)
1319 .context("provisioning plan has no target creation command")?;
1320 let read_and_export = format!("IFS= read -r {name} || exit 1; export {name};");
1321 match target {
1322 TargetTemplate::LocalPodman(_)
1323 | TargetTemplate::LocalDocker(_)
1324 | TargetTemplate::AppleContainer(_) => {
1325 let program = std::mem::replace(&mut command.program, "sh".to_owned());
1326 let args = std::mem::take(&mut command.args);
1327 command.args = vec![
1328 "-c".to_owned(),
1329 format!("{read_and_export} exec \"$@\""),
1330 "mj-secret-env".to_owned(),
1331 program,
1332 ];
1333 command.args.extend(args);
1334 }
1335 TargetTemplate::SshPodman { .. } | TargetTemplate::SshDocker { .. } => {
1336 let remote = command
1337 .args
1338 .last_mut()
1339 .context("remote container command has no SSH command argument")?;
1340 *remote = format!("{read_and_export} exec {remote}");
1341 }
1342 TargetTemplate::LocalBare
1343 | TargetTemplate::AwsEc2(_)
1344 | TargetTemplate::SshBare { .. } => {
1345 bail!("target does not support inherited container environment")
1346 }
1347 }
1348 let mut input = value.as_bytes().to_vec();
1349 input.push(b'\n');
1350 command.sensitive_stdin = Some(SensitiveCommandInput(input));
1351 Ok(())
1352 }
1353
1354 pub fn execute(&self, executor: &impl CommandExecutor) -> Result<Vec<CommandOutput>> {
1355 let mut outputs = Vec::with_capacity(self.commands.len());
1356 for command in &self.commands {
1357 let output = executor.execute(command)?;
1358 if output.status != 0 {
1359 bail!(
1360 "{} failed with status {}: {}",
1361 command.purpose,
1362 output.status,
1363 String::from_utf8_lossy(&output.stderr)
1364 );
1365 }
1366 outputs.push(output);
1367 }
1368 Ok(outputs)
1369 }
1370
1371 pub fn execute_concurrent(
1383 &self,
1384 executor: &(impl CommandExecutor + Sync),
1385 ) -> Result<Vec<CommandOutput>> {
1386 let mut outputs = Vec::with_capacity(self.commands.len());
1387 let mut index = 0;
1388 while index < self.commands.len() {
1389 let group = self.commands[index].parallel_group;
1390 let mut end = index + 1;
1391 if group.is_some() {
1392 while end < self.commands.len() && self.commands[end].parallel_group == group {
1393 end += 1;
1394 }
1395 }
1396 let batch = &self.commands[index..end];
1397 if let [command] = batch {
1398 outputs.push(checked_command_output(command, executor.execute(command)?)?);
1399 } else {
1400 let results: Vec<Result<CommandOutput>> = std::thread::scope(|scope| {
1401 let handles: Vec<_> = batch
1402 .iter()
1403 .map(|command| scope.spawn(|| executor.execute(command)))
1404 .collect();
1405 handles
1406 .into_iter()
1407 .map(|handle| match handle.join() {
1408 Ok(result) => result,
1409 Err(panic) => Err(anyhow::anyhow!(
1410 "concurrent command thread panicked: {}",
1411 command_thread_panic_message(panic.as_ref())
1412 )),
1413 })
1414 .collect()
1415 });
1416 for (command, result) in batch.iter().zip(results) {
1417 outputs.push(checked_command_output(command, result?)?);
1418 }
1419 }
1420 index = end;
1421 }
1422 Ok(outputs)
1423 }
1424
1425 pub fn split_at_target_creation(&self) -> Option<(Self, Self)> {
1433 let created = self
1434 .commands
1435 .iter()
1436 .position(|command| command.creates_target)?;
1437 let (creation, remainder) = self.commands.split_at(created + 1);
1438 Some((
1439 Self {
1440 description: self.description.clone(),
1441 commands: creation.to_vec(),
1442 },
1443 Self {
1444 description: self.description.clone(),
1445 commands: remainder.to_vec(),
1446 },
1447 ))
1448 }
1449}
1450
1451pub fn checked_command_output(
1455 command: &CommandSpec,
1456 output: CommandOutput,
1457) -> Result<CommandOutput> {
1458 if output.status != 0 {
1459 bail!(
1460 "{} failed with status {}: {}",
1461 command.purpose,
1462 output.status,
1463 String::from_utf8_lossy(&output.stderr)
1464 );
1465 }
1466 Ok(output)
1467}
1468
1469pub fn command_thread_panic_message(payload: &(dyn std::any::Any + Send)) -> String {
1471 if let Some(message) = payload.downcast_ref::<&str>() {
1472 (*message).to_owned()
1473 } else if let Some(message) = payload.downcast_ref::<String>() {
1474 message.clone()
1475 } else {
1476 "non-string panic payload".to_owned()
1477 }
1478}
1479
1480#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1481pub struct RepositorySpec {
1482 pub url: Option<String>,
1484 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1485 pub push_urls: Vec<String>,
1486 pub destination: String,
1487 pub git_ref: Option<String>,
1488 #[serde(default, skip_serializing_if = "Option::is_none")]
1491 pub reference: Option<String>,
1492}
1493
1494#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1495pub struct ProjectBundleSpec {
1496 pub primary: String,
1497 pub repositories: Vec<RepositorySpec>,
1498}
1499
1500impl ProjectBundleSpec {
1501 pub fn validate(&self) -> Result<()> {
1502 validate_relative_path(&self.primary)?;
1503 if self.repositories.is_empty() {
1504 bail!("a project bundle must contain at least one repository");
1505 }
1506 let mut destinations = std::collections::BTreeSet::new();
1507 for repository in &self.repositories {
1508 validate_relative_path(&repository.destination)?;
1509 ensure!(
1510 repository
1511 .url
1512 .as_deref()
1513 .is_some_and(|url| !url.trim().is_empty() && !url.starts_with('-')),
1514 "isolated repositories require a network Git remote; configure a remote or use a raw local session"
1515 );
1516 crate::remote_git::validate_network_url(
1517 repository.url.as_deref().expect("checked above"),
1518 )?;
1519 for push_url in &repository.push_urls {
1520 crate::remote_git::validate_network_url(push_url)?;
1521 }
1522 ensure!(
1523 repository.git_ref.is_none(),
1524 "git_ref is no longer supported; remove it to start from the remote's default branch"
1525 );
1526 if !destinations.insert(&repository.destination) {
1527 bail!(
1528 "duplicate repository destination {}",
1529 repository.destination
1530 );
1531 }
1532 }
1533 if !destinations.contains(&self.primary) {
1534 bail!("primary repository is not present in the bundle");
1535 }
1536 Ok(())
1537 }
1538}
1539
1540#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1541#[serde(tag = "kind", rename_all = "snake_case")]
1542pub enum PodmanWorkspaceStorage {
1543 PodmanVolume,
1544 HostHelper {
1545 root: String,
1546 helper: Vec<String>,
1547 },
1548 #[default]
1549 ContainerLayer,
1550}
1551
1552#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1553pub struct ContainerTemplate {
1554 pub image: String,
1555 #[serde(default)]
1556 pub pull_policy: ImagePullPolicy,
1557 #[serde(default)]
1558 pub extra_run_args: Vec<String>,
1559 #[serde(default)]
1560 pub workspace_storage: PodmanWorkspaceStorage,
1561 #[serde(default)]
1564 pub build_cache: Option<crate::config::TargetBuildCache>,
1565}
1566
1567impl ImagePullPolicy {
1568 pub fn resolve(self, image: &str) -> Self {
1571 if self != Self::Auto {
1572 return self;
1573 }
1574 if image_is_digest_pinned(image) {
1575 Self::Missing
1576 } else if image_is_remote(image) && image_uses_latest_tag(image) {
1577 Self::Newer
1578 } else {
1579 Self::Missing
1580 }
1581 }
1582
1583 pub fn at_launch(self, image: &str) -> Self {
1588 if self == Self::Auto {
1589 Self::Missing
1590 } else {
1591 self.resolve(image)
1592 }
1593 }
1594
1595 pub fn describe(self, image: &str) -> &'static str {
1600 match self {
1601 Self::Always => "Pull every launch",
1602 Self::Newer => "Pull when the registry is newer",
1603 Self::Missing => "Pull only if missing",
1604 Self::Never => "Never pull",
1605 Self::Auto => match self.resolve(image) {
1606 Self::Newer => "Pull if missing at launch; refresh :latest in background",
1607 _ => "Pull if missing",
1608 },
1609 }
1610 }
1611
1612 pub fn podman_value(self) -> &'static str {
1614 match self {
1615 Self::Always => "always",
1616 Self::Newer => "newer",
1617 Self::Missing => "missing",
1618 Self::Never => "never",
1619 Self::Auto => unreachable!("auto pull policy must resolve"),
1620 }
1621 }
1622}
1623
1624#[derive(Debug, Clone, PartialEq, Eq)]
1630pub enum ImageHost {
1631 LocalPodman,
1632 LocalDocker,
1633 AppleContainer,
1634 SshPodman(SshTarget),
1635 SshDocker(SshTarget),
1636}
1637
1638impl ImageHost {
1639 pub const fn engine(&self) -> &'static str {
1640 match self {
1641 Self::LocalPodman | Self::SshPodman(_) => "podman",
1642 Self::LocalDocker | Self::SshDocker(_) => "docker",
1643 Self::AppleContainer => "container",
1644 }
1645 }
1646
1647 pub fn label(&self) -> String {
1649 match self {
1650 Self::LocalPodman => "local podman".to_owned(),
1651 Self::LocalDocker => "local docker".to_owned(),
1652 Self::AppleContainer => "apple container".to_owned(),
1653 Self::SshPodman(ssh) => format!("podman on {}", ssh.destination),
1654 Self::SshDocker(ssh) => format!("docker on {}", ssh.destination),
1655 }
1656 }
1657
1658 fn command(&self, args: Vec<String>, purpose: String) -> CommandSpec {
1659 match self {
1660 Self::LocalPodman | Self::LocalDocker | Self::AppleContainer => {
1661 CommandSpec::new(args[0].clone(), args[1..].iter().cloned())
1662 }
1663 Self::SshPodman(ssh) | Self::SshDocker(ssh) => ssh_command_owned(ssh, args),
1664 }
1665 .purpose(purpose)
1666 }
1667}
1668
1669#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
1676pub enum RefreshWhen {
1677 WhenAbsent,
1679 Always,
1681}
1682
1683#[derive(Debug, Clone, PartialEq, Eq)]
1686pub struct ImageRefresh {
1687 pub host: ImageHost,
1688 pub image: String,
1689 pub platform: Option<String>,
1690 pub when: RefreshWhen,
1693 pub image_id: CommandSpec,
1696 pub pull: CommandSpec,
1697 pub prune: Option<CommandSpec>,
1700}
1701
1702pub fn image_refresh(
1709 host: ImageHost,
1710 image: &str,
1711 platform: Option<&str>,
1712 pull_policy: ImagePullPolicy,
1713) -> Option<ImageRefresh> {
1714 let when = match pull_policy.resolve(image) {
1715 ImagePullPolicy::Always | ImagePullPolicy::Newer => RefreshWhen::Always,
1716 ImagePullPolicy::Missing => RefreshWhen::WhenAbsent,
1717 ImagePullPolicy::Never => return None,
1718 ImagePullPolicy::Auto => unreachable!("auto pull policy must resolve"),
1719 };
1720 let engine = host.engine();
1721 let apple = matches!(host, ImageHost::AppleContainer);
1725 let mut image_id_args = vec![engine.to_owned(), "image".to_owned(), "inspect".to_owned()];
1726 if !apple {
1727 image_id_args.push("--format".to_owned());
1728 image_id_args.push("{{.Id}}".to_owned());
1729 }
1730 image_id_args.push(image.to_owned());
1731 let image_id = host.command(
1732 image_id_args,
1733 format!("read the cached id of container image {image}"),
1734 );
1735 let mut pull_args = vec![engine.to_owned()];
1736 if apple {
1737 pull_args.push("image".to_owned());
1738 }
1739 pull_args.push("pull".to_owned());
1740 if let Some(platform) = platform.filter(|_| !apple) {
1742 pull_args.push(format!("--platform={platform}"));
1743 }
1744 pull_args.push(image.to_owned());
1745 let pull = host.command(pull_args, format!("refresh container image {image}"));
1746 let prune = (!apple).then(|| {
1747 host.command(
1748 vec![
1749 engine.to_owned(),
1750 "image".to_owned(),
1751 "prune".to_owned(),
1752 "-f".to_owned(),
1753 ],
1754 "remove dangling container images".to_owned(),
1755 )
1756 });
1757 Some(ImageRefresh {
1758 host,
1759 image: image.to_owned(),
1760 platform: platform.map(str::to_owned),
1761 when,
1762 image_id,
1763 pull,
1764 prune,
1765 })
1766}
1767
1768fn image_is_digest_pinned(image: &str) -> bool {
1769 image
1770 .rsplit_once('@')
1771 .is_some_and(|(_, digest)| !digest.is_empty())
1772}
1773
1774fn image_is_remote(image: &str) -> bool {
1775 !image.starts_with("localhost/") && !image.starts_with("local/")
1776}
1777
1778fn image_uses_latest_tag(image: &str) -> bool {
1779 let name = image.split_once('@').map_or(image, |(name, _)| name);
1780 let final_component = name.rsplit('/').next().unwrap_or(name);
1781 !final_component.contains(':') || final_component.ends_with(":latest")
1782}
1783
1784#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1785pub struct SshTarget {
1786 pub destination: String,
1787 #[serde(default)]
1788 pub ssh_args: Vec<String>,
1789}
1790
1791#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1792pub struct AwsTemplate {
1793 pub profile: String,
1794 pub region: String,
1795 pub launch_template: String,
1796 pub launch_template_version: Option<String>,
1797 pub instance_type: Option<String>,
1798 pub ssh: SshTarget,
1799}
1800
1801#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1802#[serde(tag = "kind", rename_all = "snake_case")]
1803pub enum TargetTemplate {
1804 LocalBare,
1805 LocalPodman(ContainerTemplate),
1806 LocalDocker(ContainerTemplate),
1807 AppleContainer(ContainerTemplate),
1808 AwsEc2(AwsTemplate),
1809 SshBare {
1810 ssh: SshTarget,
1811 #[serde(default = "default_ssh_prefix")]
1812 workspace_prefix: String,
1813 },
1814 SshPodman {
1815 ssh: SshTarget,
1816 container: ContainerTemplate,
1817 },
1818 SshDocker {
1819 ssh: SshTarget,
1820 container: ContainerTemplate,
1821 },
1822}
1823
1824fn default_ssh_prefix() -> String {
1825 ".local/share/hel/workspaces".to_owned()
1826}
1827
1828#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1829#[serde(tag = "kind", rename_all = "snake_case")]
1830pub enum PodmanWorkspaceLocator {
1831 #[default]
1832 ContainerLayer,
1833 Volume {
1834 name: String,
1835 },
1836 HostPath {
1837 path: String,
1838 helper: Vec<String>,
1839 resource: String,
1840 },
1841}
1842
1843#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1844#[serde(tag = "kind", rename_all = "snake_case")]
1845pub enum TargetLocator {
1846 LocalBare {
1847 worker_root: String,
1848 },
1849 LocalPodman {
1850 container_id: String,
1851 #[serde(default)]
1852 workspace_storage: PodmanWorkspaceLocator,
1853 #[serde(default, skip_serializing_if = "Option::is_none")]
1857 borrowed_from: Option<String>,
1858 },
1859 LocalDocker {
1860 container_id: String,
1861 #[serde(default, skip_serializing_if = "Option::is_none")]
1865 borrowed_from: Option<String>,
1866 },
1867 AppleContainer {
1868 container_id: String,
1869 #[serde(default, skip_serializing_if = "Option::is_none")]
1873 borrowed_from: Option<String>,
1874 },
1875 AwsEc2 {
1876 profile: String,
1877 region: String,
1878 instance_id: String,
1879 ssh: SshTarget,
1880 workspace: String,
1881 },
1882 SshBare {
1883 ssh: SshTarget,
1884 workspace: String,
1885 #[serde(default, skip_serializing_if = "Option::is_none")]
1887 worker_id: Option<String>,
1888 },
1889 SshPodman {
1890 ssh: SshTarget,
1891 container_id: String,
1892 #[serde(default)]
1893 workspace_storage: PodmanWorkspaceLocator,
1894 #[serde(default, skip_serializing_if = "Option::is_none")]
1898 borrowed_from: Option<String>,
1899 },
1900 SshDocker {
1901 ssh: SshTarget,
1902 container_id: String,
1903 #[serde(default, skip_serializing_if = "Option::is_none")]
1907 borrowed_from: Option<String>,
1908 },
1909}
1910
1911impl TargetTemplate {
1912 pub const fn container_engine(&self) -> Option<&'static str> {
1913 match self {
1914 Self::LocalPodman(_) | Self::SshPodman { .. } => Some("podman"),
1915 Self::LocalDocker(_) | Self::SshDocker { .. } => Some("docker"),
1916 Self::AppleContainer(_) => Some("container"),
1917 _ => None,
1918 }
1919 }
1920
1921 pub fn image_host(&self) -> Option<(ImageHost, &ContainerTemplate)> {
1924 match self {
1925 Self::LocalPodman(container) => Some((ImageHost::LocalPodman, container)),
1926 Self::LocalDocker(container) => Some((ImageHost::LocalDocker, container)),
1927 Self::AppleContainer(container) => Some((ImageHost::AppleContainer, container)),
1928 Self::SshPodman { ssh, container } => {
1929 Some((ImageHost::SshPodman(ssh.clone()), container))
1930 }
1931 Self::SshDocker { ssh, container } => {
1932 Some((ImageHost::SshDocker(ssh.clone()), container))
1933 }
1934 Self::LocalBare | Self::AwsEc2(_) | Self::SshBare { .. } => None,
1935 }
1936 }
1937}
1938
1939impl TargetLocator {
1940 pub const fn kind_name(&self) -> &'static str {
1942 match self {
1943 Self::LocalBare { .. } => "local-bare",
1944 Self::LocalPodman { .. } => "local-podman",
1945 Self::LocalDocker { .. } => "local-docker",
1946 Self::AppleContainer { .. } => "apple-container",
1947 Self::AwsEc2 { .. } => "aws-ec2",
1948 Self::SshBare { .. } => "ssh-bare",
1949 Self::SshPodman { .. } => "ssh-podman",
1950 Self::SshDocker { .. } => "ssh-docker",
1951 }
1952 }
1953
1954 pub const fn container_engine(&self) -> Option<&'static str> {
1955 match self {
1956 Self::LocalPodman { .. } | Self::SshPodman { .. } => Some("podman"),
1957 Self::LocalDocker { .. } | Self::SshDocker { .. } => Some("docker"),
1958 Self::AppleContainer { .. } => Some("container"),
1959 _ => None,
1960 }
1961 }
1962}
1963
1964#[derive(Debug, Clone, PartialEq, Eq)]
1968pub struct TargetRecoveryPlan {
1969 pub exists: CommandSpec,
1970 pub inspect: CommandSpec,
1971 pub start: CommandSpec,
1972 pub session_id: String,
1973}
1974
1975#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1976pub enum TargetRecoveryOutcome {
1977 NotRequired,
1978 Missing,
1979 AlreadyRunning,
1980 Started,
1981}
1982
1983pub fn resource_name(session_id: &str) -> Result<String> {
1984 validate_session_id(session_id)?;
1985 let readable: String = session_id
1986 .chars()
1987 .filter(|character| character.is_ascii_alphanumeric())
1988 .take(12)
1989 .map(|character| character.to_ascii_lowercase())
1990 .collect();
1991 let digest = Sha256::digest(session_id.as_bytes());
1992 Ok(format!(
1993 "mj-{readable}-{:02x}{:02x}{:02x}",
1994 digest[0], digest[1], digest[2]
1995 ))
1996}
1997
1998pub fn move_resource_name(session_id: &str, operation_id: &str) -> Result<String> {
2000 let digest = Sha256::digest(operation_id.as_bytes());
2001 Ok(format!(
2002 "{}-move-{}",
2003 resource_name(session_id)?,
2004 crate::hex::lower_hex(&digest[..8])
2005 ))
2006}
2007
2008pub fn resource_name_belongs_to(name: &str, session_id: &str) -> Result<bool> {
2009 let base = resource_name(session_id)?;
2010 Ok(name == base
2011 || name
2012 .strip_prefix(&format!("{base}-move-"))
2013 .is_some_and(|suffix| {
2014 suffix.len() == 16 && suffix.bytes().all(|b| b.is_ascii_hexdigit())
2015 }))
2016}
2017
2018pub fn podman_workspace_locator(
2019 template: &ContainerTemplate,
2020 session_id: &str,
2021) -> Result<PodmanWorkspaceLocator> {
2022 podman_workspace_locator_named(template, &resource_name(session_id)?)
2023}
2024
2025pub fn podman_workspace_locator_named(
2026 template: &ContainerTemplate,
2027 name: &str,
2028) -> Result<PodmanWorkspaceLocator> {
2029 let resource = format!("{name}-workspace");
2030 match &template.workspace_storage {
2031 PodmanWorkspaceStorage::PodmanVolume => {
2032 Ok(PodmanWorkspaceLocator::Volume { name: resource })
2033 }
2034 PodmanWorkspaceStorage::HostHelper { root, helper } => {
2035 let root = Path::new(root);
2036 ensure!(
2037 root.is_absolute(),
2038 "Podman workspace storage root must be absolute"
2039 );
2040 ensure!(
2041 !helper.is_empty() && helper.iter().all(|argument| !argument.is_empty()),
2042 "Podman workspace storage helper must contain non-empty arguments"
2043 );
2044 Ok(PodmanWorkspaceLocator::HostPath {
2045 path: root.join(&resource).to_string_lossy().into_owned(),
2046 helper: helper.clone(),
2047 resource,
2048 })
2049 }
2050 PodmanWorkspaceStorage::ContainerLayer => Ok(PodmanWorkspaceLocator::ContainerLayer),
2051 }
2052}
2053
2054pub fn container_workspace_root(recorded: Option<&Path>) -> String {
2062 recorded.map_or_else(
2063 || CONTAINER_WORKSPACE.to_owned(),
2064 |path| path.to_string_lossy().into_owned(),
2065 )
2066}
2067
2068pub fn new_container_workspace(session_id: &str) -> Result<PathBuf> {
2070 validate_session_id(session_id)?;
2071 Ok(Path::new(CONTAINER_WORKSPACE).join(session_id))
2072}
2073
2074pub fn aws_workspace(session_id: &str) -> String {
2080 format!(".local/share/hel/workspaces/{session_id}")
2081}
2082
2083pub fn workspace_for(template: &TargetTemplate, session_id: &str) -> Result<String> {
2084 validate_session_id(session_id)?;
2085 match template {
2086 TargetTemplate::LocalBare => bail!("local bare projects use their selected directory"),
2087 TargetTemplate::LocalPodman(_)
2090 | TargetTemplate::LocalDocker(_)
2091 | TargetTemplate::AppleContainer(_)
2092 | TargetTemplate::SshPodman { .. }
2093 | TargetTemplate::SshDocker { .. } => {
2094 bail!("container targets use the session's recorded container workspace")
2095 }
2096 TargetTemplate::AwsEc2(_) => Ok(aws_workspace(session_id)),
2097 TargetTemplate::SshBare {
2098 workspace_prefix, ..
2099 } => {
2100 validate_workspace_prefix(workspace_prefix)?;
2101 let prefix = workspace_prefix
2106 .strip_prefix("~/")
2107 .unwrap_or(workspace_prefix);
2108 Ok(format!("{}/{session_id}", prefix.trim_end_matches('/')))
2109 }
2110 }
2111}
2112
2113pub fn command_on_locator(
2115 locator: &TargetLocator,
2116 session_id: &str,
2117 args: Vec<String>,
2118 purpose: impl Into<String>,
2119) -> Result<CommandSpec> {
2120 verify_locator(locator, session_id)?;
2121 if args.is_empty() {
2122 bail!("target command must not be empty");
2123 }
2124 Ok(locator_command(locator, args).purpose(purpose))
2125}
2126
2127pub fn locator_command(locator: &TargetLocator, args: Vec<String>) -> CommandSpec {
2131 match locator {
2132 TargetLocator::LocalBare { .. } => {
2133 let mut args = args.into_iter();
2134 let program = args.next().expect("target command must not be empty");
2135 CommandSpec::new(program, args)
2136 }
2137 TargetLocator::LocalPodman { container_id, .. }
2138 | TargetLocator::LocalDocker { container_id, .. }
2139 | TargetLocator::AppleContainer { container_id, .. } => container_exec(
2140 locator.container_engine().expect("local container"),
2141 container_id,
2142 args,
2143 ),
2144 TargetLocator::AwsEc2 { ssh, .. } | TargetLocator::SshBare { ssh, .. } => {
2145 ssh_command_owned(ssh, args)
2146 }
2147 TargetLocator::SshPodman {
2148 ssh, container_id, ..
2149 }
2150 | TargetLocator::SshDocker {
2151 ssh, container_id, ..
2152 } => {
2153 let mut remote = vec![
2154 locator
2155 .container_engine()
2156 .expect("remote container")
2157 .to_owned(),
2158 "exec".to_owned(),
2159 "-i".to_owned(),
2160 container_id.to_owned(),
2161 ];
2162 remote.extend(args);
2163 ssh_command_owned(ssh, remote)
2164 }
2165 }
2166}
2167pub fn worker_root(locator: &TargetLocator, session_id: &str) -> Result<String> {
2168 verify_locator(locator, session_id)?;
2169 Ok(match locator {
2170 TargetLocator::LocalBare { worker_root } => worker_root.clone(),
2171 TargetLocator::LocalPodman { .. }
2172 | TargetLocator::LocalDocker { .. }
2173 | TargetLocator::AppleContainer { .. }
2174 | TargetLocator::SshPodman { .. }
2175 | TargetLocator::SshDocker { .. } => format!("/var/lib/hel/workers/{session_id}"),
2176 TargetLocator::AwsEc2 { .. } => format!(".local/share/hel/workers/{session_id}"),
2177 TargetLocator::SshBare { worker_id, .. } => format!(
2178 ".local/share/hel/workers/{}",
2179 worker_id.as_deref().unwrap_or(session_id)
2180 ),
2181 })
2182}
2183mod convert;
2184pub use convert::{
2185 RecordedTarget, StoredTarget, TargetConversionError, locator_needs_connection,
2186 ssh_args_with_identity,
2187};
2188
2189mod ssh;
2190pub use ssh::*;
2191
2192pub fn container_exec(
2193 engine: &str,
2194 container_id: &str,
2195 args: impl IntoIterator<Item = impl Into<String>>,
2196) -> CommandSpec {
2197 let mut command_args = vec!["exec".to_owned(), "-i".to_owned(), container_id.to_owned()];
2198 command_args.extend(args.into_iter().map(Into::into));
2199 CommandSpec::new(engine, command_args)
2200}
2201
2202#[cfg(all(test, unix))]
2203mod executor_tests {
2204 use std::fs;
2205
2206 use super::*;
2207
2208 #[cfg(target_os = "linux")]
2209 #[test]
2210 fn successful_commands_stop_descendants_that_closed_their_pipes() {
2211 for streamed in [false, true] {
2212 let temp = tempfile::tempdir().unwrap();
2213 let pid_file = temp.path().join("descendant");
2214 let command = CommandSpec::new("sh", [
2215 "-c".to_owned(),
2216 "cat >/dev/null; head -c 131072 /dev/zero; sleep 60 </dev/null >/dev/null 2>&1 & echo $! > \"$1\"".to_owned(),
2217 "owned-descendant".to_owned(), pid_file.display().to_string(),
2218 ]);
2219 let executor = CancellableProcessExecutor::with_timeout(Duration::from_secs(5));
2220 let output = if streamed {
2221 executor
2222 .execute_with_stdin(&command, &mut std::io::Cursor::new(vec![b'x'; 256 * 1024]))
2223 } else {
2224 executor.execute(&command)
2225 }
2226 .unwrap();
2227 assert_eq!(output.stdout.len(), 131072);
2228 let pid: i32 = fs::read_to_string(pid_file)
2229 .unwrap()
2230 .trim()
2231 .parse()
2232 .unwrap();
2233 let deadline = Instant::now() + Duration::from_secs(2);
2234 loop {
2235 let state = fs::read_to_string(format!("/proc/{pid}/stat")).ok();
2236 if state.as_ref().is_none_or(|state| {
2237 state
2238 .rsplit_once(") ")
2239 .is_some_and(|(_, fields)| fields.starts_with('Z'))
2240 }) {
2241 break;
2242 }
2243 if Instant::now() >= deadline {
2244 unsafe {
2246 libc::kill(pid, libc::SIGKILL);
2247 }
2248 panic!(
2249 "successful command left descendant {pid} running (streamed={streamed})"
2250 );
2251 }
2252 std::thread::sleep(Duration::from_millis(10));
2253 }
2254 }
2255 }
2256
2257 #[cfg(target_os = "linux")]
2260 #[test]
2261 fn commands_complete_at_leader_exit_when_a_descendant_holds_the_pipes() {
2262 for streamed in [false, true] {
2263 let temp = tempfile::tempdir().unwrap();
2264 let pid_file = temp.path().join("descendant");
2265 let command = CommandSpec::new(
2266 "sh",
2267 [
2268 "-c".to_owned(),
2269 "sleep 300 & echo $! > \"$1\"; echo hi; exit 3".to_owned(),
2270 "held-pipes".to_owned(),
2271 pid_file.display().to_string(),
2272 ],
2273 );
2274 let executor = CancellableProcessExecutor::with_timeout(Duration::from_secs(30));
2275 let started = Instant::now();
2276 let output = if streamed {
2277 executor.execute_with_stdin(&command, &mut std::io::Cursor::new(b"input".to_vec()))
2278 } else {
2279 executor.execute(&command)
2280 }
2281 .unwrap();
2282 assert!(
2283 started.elapsed() < Duration::from_secs(5),
2284 "streamed={streamed} took {:?}",
2285 started.elapsed()
2286 );
2287 assert_eq!(output.status, 3);
2288 assert_eq!(output.stdout, b"hi\n");
2289 let pid: i32 = fs::read_to_string(pid_file)
2290 .unwrap()
2291 .trim()
2292 .parse()
2293 .unwrap();
2294 let deadline = Instant::now() + Duration::from_secs(2);
2295 loop {
2296 let state = fs::read_to_string(format!("/proc/{pid}/stat")).ok();
2297 if state.as_ref().is_none_or(|state| {
2298 state
2299 .rsplit_once(") ")
2300 .is_some_and(|(_, fields)| fields.starts_with('Z'))
2301 }) {
2302 break;
2303 }
2304 if Instant::now() >= deadline {
2305 unsafe {
2307 libc::kill(pid, libc::SIGKILL);
2308 }
2309 panic!("completed command left descendant {pid} (streamed={streamed})");
2310 }
2311 std::thread::sleep(Duration::from_millis(10));
2312 }
2313 }
2314 }
2315
2316 #[test]
2317 fn streamed_deadline_survives_leader_exit_and_inherited_pipes() {
2318 let command = CommandSpec::new(
2319 "sh",
2320 [
2321 "-c",
2322 "head -c 131072 /dev/zero; cat >/dev/null; (trap '' TERM; sleep 60) & exit 0",
2323 ],
2324 );
2325 let mut input = std::io::Cursor::new(vec![b'x'; 256 * 1024]);
2326 let started = Instant::now();
2327 let error = CancellableProcessExecutor::with_timeout(Duration::from_millis(300))
2328 .execute_with_stdin(&command, &mut input)
2329 .unwrap_err();
2330 assert!(error.to_string().contains("cancelled"), "{error:#}");
2331 assert!(started.elapsed() < Duration::from_secs(5));
2332 }
2333
2334 fn flaky_ssh_script(directory: &Path) -> CommandSpec {
2338 let counter = directory.join("attempts");
2339 let script = format!(
2340 "count=$(cat {counter} 2>/dev/null || echo 0)\n\
2341 echo $((count + 1)) > {counter}\n\
2342 if [ \"$count\" -eq 0 ]; then\n\
2343 echo 'kex_exchange_identification: Connection closed by 10.0.0.1 port 22' >&2\n\
2344 exit 255\n\
2345 fi\n\
2346 echo connected\n",
2347 counter = counter.display()
2348 );
2349 CommandSpec::new("sh", ["-c".to_owned(), script])
2350 .ssh_destination("build@10.0.0.1")
2351 .purpose("run the flaky SSH fixture")
2352 }
2353
2354 fn attempts(directory: &Path) -> u32 {
2355 fs::read_to_string(directory.join("attempts"))
2356 .expect("the fixture records its attempts")
2357 .trim()
2358 .parse()
2359 .expect("attempt count is a number")
2360 }
2361
2362 #[derive(Default)]
2367 struct MasterKilledOnce {
2368 running: std::cell::RefCell<BTreeSet<String>>,
2369 openers: std::cell::Cell<usize>,
2370 sessions: std::cell::RefCell<Vec<Vec<String>>>,
2371 }
2372
2373 impl CommandExecutor for MasterKilledOnce {
2374 fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
2375 let reply = |status: i32, stderr: &str| CommandOutput {
2376 status,
2377 stdout: Vec::new(),
2378 stderr: stderr.as_bytes().to_vec(),
2379 };
2380 if command.ssh_session.is_some() {
2381 return with_ssh_admission(command, self, &|| false, |spawned| {
2383 self.sessions.borrow_mut().push(spawned.args.clone());
2384 if self.sessions.borrow().len() == 1 {
2385 self.running.borrow_mut().clear();
2386 return Ok(reply(255, "Connection closed by UNKNOWN port 65535"));
2387 }
2388 Ok(reply(0, ""))
2389 });
2390 }
2391 let socket = command
2392 .args
2393 .iter()
2394 .find_map(|arg| arg.strip_prefix("ControlPath="))
2395 .expect("a master command names its socket")
2396 .to_owned();
2397 if command.args.windows(2).any(|pair| pair == ["-O", "check"]) {
2398 return Ok(reply(
2399 if self.running.borrow().contains(&socket) {
2400 0
2401 } else {
2402 255
2403 },
2404 "",
2405 ));
2406 }
2407 assert!(command.args.contains(&"ControlMaster=yes".to_owned()));
2408 self.openers.set(self.openers.get() + 1);
2409 self.running.borrow_mut().insert(socket);
2410 Ok(reply(0, ""))
2411 }
2412 }
2413
2414 #[test]
2415 fn a_session_whose_master_died_is_retried_on_a_reopened_master() {
2416 let _guard = ssh::SHARING_TEST_LOCK
2417 .lock()
2418 .unwrap_or_else(std::sync::PoisonError::into_inner);
2419 set_ssh_retry_backoff_for_test(Some(Duration::from_millis(5)));
2420 let socket_dir = tempfile::tempdir_in("/tmp").expect("short socket directory");
2421 set_ssh_connection_sharing_for_test(Some(SshSharingForTest::Directory(
2422 socket_dir.path().to_path_buf(),
2423 )));
2424 let ssh = SshTarget {
2425 destination: "master-killed-once-host".to_owned(),
2426 ssh_args: Vec::new(),
2427 };
2428 let executor = MasterKilledOnce::default();
2429 let output = executor.execute(&ssh_command(&ssh, ["true"]));
2430 set_ssh_connection_sharing_for_test(None);
2431 set_ssh_retry_backoff_for_test(None);
2432
2433 assert_eq!(output.expect("the retry succeeds").status, 0);
2434 let sessions = executor.sessions.borrow();
2435 assert_eq!(sessions.len(), 2);
2436 assert_eq!(
2437 executor.openers.get(),
2438 2,
2439 "the retry reopens the master instead of trusting the earlier check"
2440 );
2441 for args in sessions.iter() {
2442 assert_eq!(args[..6][5], "ProxyCommand=false", "{args:?}");
2443 }
2444 }
2445
2446 #[test]
2447 fn a_transport_rejected_ssh_command_is_retried_once_and_then_succeeds() {
2448 set_ssh_retry_backoff_for_test(Some(Duration::from_millis(5)));
2449 let directory = tempfile::tempdir().expect("temp dir");
2450 let command = flaky_ssh_script(directory.path());
2451
2452 let output = ProcessExecutor
2453 .execute(&command)
2454 .expect("the retry must reach the successful attempt");
2455
2456 assert_eq!(output.status, 0);
2457 assert_eq!(String::from_utf8_lossy(&output.stdout).trim(), "connected");
2458 assert_eq!(attempts(directory.path()), 2);
2459 set_ssh_retry_backoff_for_test(None);
2460 }
2461
2462 #[test]
2463 fn an_untagged_command_is_not_retried_after_the_same_failure() {
2464 set_ssh_retry_backoff_for_test(Some(Duration::from_millis(5)));
2465 let directory = tempfile::tempdir().expect("temp dir");
2466 let mut command = flaky_ssh_script(directory.path());
2467 command.ssh_destination = None;
2468
2469 let output = ProcessExecutor.execute(&command).expect("runs once");
2470
2471 assert_eq!(output.status, 255);
2472 assert_eq!(attempts(directory.path()), 1);
2473 set_ssh_retry_backoff_for_test(None);
2474 }
2475
2476 #[test]
2477 fn the_cancellable_executor_also_retries_a_transport_rejection() {
2478 set_ssh_retry_backoff_for_test(Some(Duration::from_millis(5)));
2479 let directory = tempfile::tempdir().expect("temp dir");
2480 let command = flaky_ssh_script(directory.path());
2481
2482 let output = CancellableProcessExecutor::new(Arc::new(AtomicBool::new(false)))
2483 .execute(&command)
2484 .expect("the retry must reach the successful attempt");
2485
2486 assert_eq!(output.status, 0);
2487 assert_eq!(attempts(directory.path()), 2);
2488 set_ssh_retry_backoff_for_test(None);
2489 }
2490}