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