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