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