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