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(skip)]
153 preserve_children_on_cancel: bool,
154 #[serde(default, skip_serializing_if = "Option::is_none")]
159 pub ssh_destination: Option<String>,
160 #[serde(default, skip_serializing_if = "Option::is_none")]
167 pub ssh_session: Option<SshTarget>,
168 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
171 pub ssh_session_probe: bool,
172 #[serde(skip)]
175 sensitive_stdin: Option<SensitiveCommandInput>,
176}
177
178impl CommandSpec {
179 pub fn new(
180 program: impl Into<String>,
181 args: impl IntoIterator<Item = impl Into<String>>,
182 ) -> Self {
183 Self {
184 program: program.into(),
185 args: args.into_iter().map(Into::into).collect(),
186 env: BTreeMap::new(),
187 clear_env: false,
188 cwd: None,
189 purpose: String::new(),
190 stage: None,
191 parallel_group: None,
192 creates_target: false,
193 detaches: false,
194 preserve_children_on_cancel: false,
195 ssh_destination: None,
196 ssh_session: None,
197 ssh_session_probe: false,
198 sensitive_stdin: None,
199 }
200 }
201
202 pub fn purpose(mut self, purpose: impl Into<String>) -> Self {
203 self.purpose = purpose.into();
204 self
205 }
206
207 pub fn stage(mut self, stage: ProvisionStage) -> Self {
208 self.stage = Some(stage);
209 self
210 }
211
212 pub fn parallel_group(mut self, group: u32) -> Self {
215 self.parallel_group = Some(group);
216 self
217 }
218
219 pub fn ssh_destination(mut self, destination: impl Into<String>) -> Self {
222 self.ssh_destination = Some(destination.into());
223 self
224 }
225
226 pub fn ssh_session(mut self, ssh: &SshTarget) -> Self {
230 self.ssh_destination = Some(ssh.destination.clone());
231 self.ssh_session = Some(ssh.clone());
232 self
233 }
234
235 pub fn ssh_probe_session(mut self, ssh: &SshTarget) -> Self {
242 self = self.ssh_session(ssh);
243 self.ssh_session_probe = true;
244 self
245 }
246
247 pub fn open_ssh_session(&self, executor: &dyn CommandExecutor) -> Result<SessionCommand<'_>> {
253 let Some(ssh) = &self.ssh_session else {
254 return Ok(SessionCommand {
255 command: std::borrow::Cow::Borrowed(self),
256 lease: None,
257 });
258 };
259 let _waiting = BlockingOperation::start(
260 &format!(
261 "lease SSH connection to {} for {}",
262 ssh.destination, self.purpose
263 ),
264 "ssh",
265 );
266 let lease = if self.ssh_session_probe {
267 SshSessions::lease_probe(ssh)
268 } else {
269 SshSessions::lease(ssh, executor)?
270 };
271 let mut command = self.clone();
272 command.ssh_session = None;
273 command.ssh_session_probe = false;
274 command.args = session_command_args(&self.program, &self.args, ssh, &lease);
275 Ok(SessionCommand {
276 command: std::borrow::Cow::Owned(command),
277 lease: Some(lease),
278 })
279 }
280
281 pub fn creates_target(mut self) -> Self {
283 self.creates_target = true;
284 self
285 }
286
287 pub fn preserve_children_on_cancel(mut self) -> Self {
289 self.preserve_children_on_cancel = true;
290 self
291 }
292
293 pub fn with_sensitive_stdin(mut self, input: Vec<u8>) -> Self {
296 self.sensitive_stdin = Some(SensitiveCommandInput(input));
297 self
298 }
299}
300
301#[cfg(test)]
302mod podman_exec_tests {
303 use super::*;
304
305 #[test]
306 fn podman_exec_keeps_the_container_configured_user() {
307 assert_eq!(
308 CommandSpec::new("podman", ["exec", "--detach", "session", "true"]).args,
309 ["exec", "--detach", "session", "true"]
310 );
311 assert_eq!(
312 CommandSpec::new("podman", ["exec", "--user", "1000:1000", "session", "id"]).args,
313 ["exec", "--user", "1000:1000", "session", "id"]
314 );
315 assert_eq!(
316 CommandSpec::new("podman", ["exec", "session", "tool", "--user", "42"]).args,
317 ["exec", "session", "tool", "--user", "42"]
318 );
319 assert_eq!(
320 CommandSpec::new("docker", ["exec", "session", "id"]).args,
321 ["exec", "session", "id"]
322 );
323 }
324}
325
326#[derive(Debug)]
330pub struct SessionCommand<'a> {
331 command: std::borrow::Cow<'a, CommandSpec>,
332 lease: Option<SshSessionLease>,
333}
334
335impl SessionCommand<'_> {
336 pub fn command(&self) -> &CommandSpec {
337 &self.command
338 }
339
340 pub fn lease(&self) -> Option<&SshSessionLease> {
341 self.lease.as_ref()
342 }
343
344 pub fn into_parts(self) -> (CommandSpec, Option<SshSessionLease>) {
345 (self.command.into_owned(), self.lease)
346 }
347}
348
349#[derive(Debug, Clone, PartialEq, Eq)]
350pub struct CommandOutput {
351 pub status: i32,
352 pub stdout: Vec<u8>,
353 pub stderr: Vec<u8>,
354}
355
356#[derive(Debug, Clone, Copy, PartialEq, Eq)]
357pub enum DeploymentCapacityKind {
358 Host,
359 AwsFleet,
360}
361
362#[derive(Debug, Clone, PartialEq, Eq)]
363pub struct DeploymentCapacityTarget {
364 pub id: String,
365 pub host: String,
366 pub target_ids: Vec<String>,
367 pub kind: DeploymentCapacityKind,
368 pub local: bool,
369 pub probes: Vec<CommandSpec>,
373 pub local_storage_paths: Vec<String>,
376 pub probe_error: Option<String>,
378}
379
380#[derive(Debug, Clone, PartialEq, Eq)]
381pub struct DeploymentCapacityUsage {
382 pub cpu_percent: Option<u8>,
383 pub memory_used_bytes: u64,
384 pub memory_total_bytes: u64,
385 pub logical_cores: u64,
386 pub disk_total_bytes: Option<u64>,
387 pub storage: Vec<storage::HostStorageSample>,
390}
391
392#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
398#[serde(from = "AdditionalMountRepr", into = "AdditionalMountRepr")]
399pub struct AdditionalMount {
400 pub source: PathBuf,
401 pub destination: PathBuf,
402 pub access: MountAccess,
403}
404
405#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
407#[serde(rename_all = "snake_case")]
408pub enum MountAccess {
409 Ro,
411 Cow,
414 Rw,
416}
417
418impl MountAccess {
419 pub const ALL: [Self; 3] = [Self::Ro, Self::Cow, Self::Rw];
420
421 pub fn label(self) -> &'static str {
422 match self {
423 Self::Ro => "ro",
424 Self::Cow => "cow",
425 Self::Rw => "rw",
426 }
427 }
428
429 pub fn without_overlay(self) -> Self {
438 match self {
439 Self::Cow => Self::Ro,
440 kept => kept,
441 }
442 }
443
444 pub fn offered(overlay_available: bool) -> Vec<Self> {
447 Self::ALL
448 .into_iter()
449 .filter(|access| overlay_available || access.without_overlay() == *access)
450 .collect()
451 }
452}
453
454#[derive(Serialize, Deserialize)]
459#[serde(deny_unknown_fields)]
460struct AdditionalMountRepr {
461 source: PathBuf,
462 destination: PathBuf,
463 #[serde(default)]
464 read_only: bool,
465 #[serde(default, skip_serializing_if = "Option::is_none")]
466 access: Option<MountAccess>,
467}
468
469impl From<AdditionalMountRepr> for AdditionalMount {
470 fn from(repr: AdditionalMountRepr) -> Self {
471 let access = repr.access.unwrap_or(if repr.read_only {
472 MountAccess::Ro
473 } else {
474 MountAccess::Cow
475 });
476 Self {
477 source: repr.source,
478 destination: repr.destination,
479 access,
480 }
481 }
482}
483
484impl From<AdditionalMount> for AdditionalMountRepr {
485 fn from(mount: AdditionalMount) -> Self {
486 Self {
487 source: mount.source,
488 destination: mount.destination,
489 read_only: mount.access == MountAccess::Ro,
490 access: (mount.access == MountAccess::Rw).then_some(MountAccess::Rw),
491 }
492 }
493}
494
495pub fn overlay_unsupported_filesystem(filesystem: &str) -> Option<&'static str> {
501 let name = filesystem.trim().to_ascii_lowercase();
502 if name == "fuse" || name == "fuseblk" || name.starts_with("fuse.") {
504 return Some("FUSE filesystem");
505 }
506 match name.as_str() {
507 "nfs" | "nfs4" | "cifs" | "smb2" | "smb3" | "9p" | "v9fs" | "virtiofs" | "ceph"
508 | "lustre" | "afs" | "glusterfs" | "ocfs2" | "gfs" | "gfs2" => Some("network filesystem"),
509 "msdos" | "vfat" | "fat" | "exfat" | "ntfs" | "ntfs3" => Some("no POSIX metadata"),
510 "overlayfs" => Some("overlay stacking limit"),
511 _ => None,
512 }
513}
514
515pub fn validate_mount_destination(path: &Path) -> Result<()> {
517 ensure!(
518 crate::target_path::is_absolute(path)
519 && !path
520 .components()
521 .any(|part| part == std::path::Component::ParentDir),
522 "additional mount destination must be a safe absolute container path; ~ is not supported"
523 );
524 Ok(())
525}
526
527pub fn validate_additional_mounts(mounts: &[AdditionalMount]) -> Result<()> {
528 let mut destinations = BTreeSet::new();
529 for mount in mounts {
530 if !crate::target_path::is_absolute_on_host_or_target(&mount.source)
531 || mount.source.as_os_str().is_empty()
532 {
533 bail!("additional mount source must be an absolute directory path");
534 }
535 validate_mount_destination(&mount.destination)?;
536 if !destinations.insert(mount.destination.clone()) {
537 bail!(
538 "additional mount destination {:?} is configured more than once",
539 mount.destination
540 );
541 }
542 }
543 Ok(())
544}
545
546pub fn default_mount_destination(source: &Path, existing: &[AdditionalMount]) -> PathBuf {
548 let basename = source
549 .file_name()
550 .filter(|name| !name.is_empty())
551 .unwrap_or_else(|| std::ffi::OsStr::new("mount"));
552 let base = PathBuf::from("/mnt").join(basename);
553 if !existing.iter().any(|mount| mount.destination == base) {
554 return base;
555 }
556 for number in 2.. {
557 let candidate =
558 PathBuf::from("/mnt").join(format!("{}-{number}", basename.to_string_lossy()));
559 if !existing.iter().any(|mount| mount.destination == candidate) {
560 return candidate;
561 }
562 }
563 unreachable!("a finite mount list always has an unused numbered destination")
564}
565
566pub trait CommandExecutor {
567 fn execute(&self, command: &CommandSpec) -> Result<CommandOutput>;
568
569 fn execute_cleanup(&self, command: &CommandSpec) -> Result<CommandOutput> {
572 self.execute(command)
573 }
574
575 fn execute_cleanup_with_timeout(
580 &self,
581 command: &CommandSpec,
582 _timeout: Duration,
583 ) -> Result<CommandOutput> {
584 self.execute_cleanup(command)
585 }
586
587 fn cancellation_requested(&self) -> bool {
591 false
592 }
593
594 fn stage_started(&self, _stage: ProvisionStage) {}
598
599 fn stage_finished(&self, _stage: ProvisionStage) {}
602
603 fn notify_notice(&self, _notice: &str) {}
606
607 fn before_move_source_stop(
609 &self,
610 ) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<()>> + Send + '_>> {
611 Box::pin(async { Ok(()) })
612 }
613
614 fn reserve_move_destination(&self) {}
617
618 fn begin_resumable_move_work(&self) -> Result<()> {
620 Ok(())
621 }
622 fn end_resumable_move_work(&self) -> Result<()> {
623 Ok(())
624 }
625
626 fn execute_with_stdin(
627 &self,
628 _command: &CommandSpec,
629 _input: &mut (dyn Read + Send),
630 ) -> Result<CommandOutput> {
631 bail!("this command executor does not support streamed stdin")
632 }
633}
634
635pub struct ProvisionStageGuard<'a, E: CommandExecutor + ?Sized> {
639 executor: &'a E,
640 stage: ProvisionStage,
641}
642
643impl<'a, E: CommandExecutor + ?Sized> ProvisionStageGuard<'a, E> {
644 pub fn new(executor: &'a E, stage: ProvisionStage) -> Self {
645 executor.stage_started(stage.clone());
646 Self { executor, stage }
647 }
648}
649
650impl<E: CommandExecutor + ?Sized> Drop for ProvisionStageGuard<'_, E> {
651 fn drop(&mut self) {
652 self.executor.stage_finished(self.stage.clone());
653 }
654}
655
656pub struct ProcessExecutor;
657
658fn with_ssh_admission(
674 command: &CommandSpec,
675 executor: &dyn CommandExecutor,
676 is_cancelled: &dyn Fn() -> bool,
677 mut run: impl FnMut(&CommandSpec) -> Result<CommandOutput>,
678) -> Result<CommandOutput> {
679 let Some(destination) = command.ssh_destination.as_deref() else {
680 return run(command);
681 };
682 for attempt in 1..=SSH_RETRY_ATTEMPTS {
683 let session = command.open_ssh_session(executor)?;
684 let output = {
685 let _permit = SshAdmission::acquire_unless(destination, is_cancelled)?;
686 run(session.command())?
687 };
688 let refusal = ssh_refusal(output.status, &String::from_utf8_lossy(&output.stderr));
689 if refusal == Some(SshRefusal::BeforeAuthentication)
692 && let Some(lease) = session.lease()
693 {
694 lease.invalidate();
695 }
696 drop(session);
697 let Some(refusal) = refusal else {
698 return Ok(output);
699 };
700 let stderr = String::from_utf8_lossy(&output.stderr);
701 if attempt == SSH_RETRY_ATTEMPTS {
702 refusal.log_exhausted(destination, &command.purpose, stderr.trim());
703 return Ok(output);
704 }
705 let delay = ssh_retry_delay(attempt);
706 refusal.log_retry(destination, &command.purpose, attempt, delay, stderr.trim());
707 if !sleep_unless_cancelled(delay, is_cancelled) {
708 bail!("operation cancelled while {}", command.purpose);
709 }
710 }
711 unreachable!("the final attempt always returns");
712}
713
714fn sleep_unless_cancelled(delay: Duration, is_cancelled: &dyn Fn() -> bool) -> bool {
717 let deadline = Instant::now() + delay;
718 loop {
719 if is_cancelled() {
720 return false;
721 }
722 let remaining = deadline.saturating_duration_since(Instant::now());
723 if remaining.is_zero() {
724 return true;
725 }
726 std::thread::sleep(remaining.min(Duration::from_millis(50)));
727 }
728}
729
730fn finished_command(
734 command: &CommandSpec,
735 started: Instant,
736 status: i32,
737 stdout: Vec<u8>,
738 stderr: Vec<u8>,
739) -> CommandOutput {
740 tracing::debug!(
741 purpose = command.purpose.as_str(),
742 program = command.program.as_str(),
743 status,
744 elapsed_ms = started.elapsed().as_millis() as u64,
745 "target command finished"
746 );
747 let output = CommandOutput {
748 status,
749 stdout,
750 stderr,
751 };
752 storage::observe_command_output(command, &output);
753 output
754}
755
756impl ProcessExecutor {
757 fn run_once(&self, command: &CommandSpec) -> Result<CommandOutput> {
759 if let Some(input) = &command.sensitive_stdin {
760 let mut input = std::io::Cursor::new(input.0.as_slice());
761 return stream_command_with_stdin(
763 cancellable_command(command),
764 command,
765 &mut input,
766 &|| false,
767 );
768 }
769 let started = Instant::now();
770 let output = configured_command(command)
771 .stdin(Stdio::null())
772 .output()
773 .with_context(|| format!("run {} for {}", command.program, command.purpose))?;
774 let status = output.status.code().unwrap_or(-1);
775 Ok(finished_command(
776 command,
777 started,
778 status,
779 output.stdout,
780 output.stderr,
781 ))
782 }
783}
784
785impl CommandExecutor for ProcessExecutor {
790 fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
791 crate::runtime::off_async_worker(|| {
792 let _running = BlockingOperation::command(command);
793 with_ssh_admission(command, self, &|| false, |command| self.run_once(command))
794 })
795 }
796
797 fn execute_with_stdin(
798 &self,
799 command: &CommandSpec,
800 input: &mut (dyn Read + Send),
801 ) -> Result<CommandOutput> {
802 crate::runtime::off_async_worker(|| {
803 let _running = BlockingOperation::command(command);
804 let session = command.open_ssh_session(self)?;
807 let _permit = command
808 .ssh_destination
809 .as_deref()
810 .map(SshAdmission::acquire);
811 let command = session.command();
812 let process = cancellable_command(command);
813 stream_command_with_stdin(process, command, input, &|| false)
816 })
817 }
818}
819
820fn stream_command_with_stdin(
829 mut process: Command,
830 command: &CommandSpec,
831 input: &mut (dyn Read + Send),
832 is_cancelled: &(dyn Fn() -> bool + Sync),
833) -> Result<CommandOutput> {
834 let started = Instant::now();
835 if is_cancelled() {
836 bail!("operation cancelled");
837 }
838 let mut child = process
839 .stdin(Stdio::piped())
840 .stdout(Stdio::piped())
841 .stderr(Stdio::piped())
842 .spawn()
843 .with_context(|| format!("run {} for {}", command.program, command.purpose))?;
844 let group =
845 (!command.detaches).then(|| crate::subprocess::ProcessGroupGuard::new(Some(child.id())));
846 let stdin = child
847 .stdin
848 .take()
849 .context("streamed command stdin missing")?;
850 let stdout = child
851 .stdout
852 .take()
853 .context("streamed command stdout missing")?;
854 let stderr = child
855 .stderr
856 .take()
857 .context("streamed command stderr missing")?;
858 let stdout_reader = PipeCollector::spawn(stdout);
861 let stderr_reader = PipeCollector::spawn(stderr);
862 let process_result = std::thread::scope(|scope| -> Result<_> {
863 let input_writer = scope.spawn(move || -> Result<()> {
867 let mut stdin = stdin;
872 let mut buffer = [0_u8; 64 * 1024];
873 loop {
874 if is_cancelled() {
878 bail!("operation cancelled");
879 }
880 let count = input.read(&mut buffer).context("read command input")?;
881 if count == 0 {
882 break;
883 }
884 stdin
885 .write_all(&buffer[..count])
886 .context("stream command input")?;
887 }
888 stdin.flush().context("flush command input")
889 });
890 let mut status = None;
891 let mut exited_at = None;
892 let mut group_killed = false;
893 let status = loop {
894 if is_cancelled() {
895 terminate_cancellable_child(&mut child, false);
896 if let Err(error) = input_writer.join() {
897 tracing::warn!(
898 purpose = command.purpose.as_str(),
899 "streamed command input writer panicked while cancelling: {error:?}"
900 );
901 }
902 bail!("operation cancelled while {}", command.purpose);
903 }
904 match if status.is_some() {
905 Ok(status)
906 } else {
907 child.try_wait()
908 } {
909 Ok(observed) => status = observed,
910 Err(error) => {
911 terminate_cancellable_child(&mut child, false);
912 if let Err(join_error) = input_writer.join() {
913 tracing::warn!(
914 purpose = command.purpose.as_str(),
915 "streamed command input writer panicked while waiting: {join_error:?}"
916 );
917 }
918 return Err(error).with_context(|| format!("wait for {}", command.purpose));
919 }
920 }
921 if let Some(status) = status {
926 let exited_at = *exited_at.get_or_insert_with(Instant::now);
927 let drained = (stdout_reader.is_finished() && stderr_reader.is_finished())
928 || exited_at.elapsed() >= IO_DRAIN_TIMEOUT;
929 if drained && !input_writer.is_finished() && !group_killed {
930 group_killed = true;
931 if let Some(group) = &group {
932 group.kill();
933 }
934 }
935 if drained && input_writer.is_finished() {
936 break status;
937 }
938 }
939 std::thread::sleep(Duration::from_millis(25));
940 };
941 let input_result = input_writer
942 .join()
943 .map_err(|_| anyhow::anyhow!("streamed command input writer panicked"))?;
944 Ok((status, input_result))
945 });
946 let deadline = Instant::now();
947 let stdout = stdout_reader.finish("stdout", deadline)?;
948 let stderr = stderr_reader.finish("stderr", deadline)?;
949 let (status, input_result) = process_result?;
950 drop(group);
951 if status.success() {
952 input_result?;
956 }
957 let status = status.code().unwrap_or(-1);
958 Ok(finished_command(command, started, status, stdout, stderr))
959}
960
961#[derive(Clone)]
962pub struct CancellableProcessExecutor {
963 cancelled: Arc<AtomicBool>,
964 deadline: Option<Instant>,
965}
966
967pub struct ProcessCancellationGuard(Arc<AtomicBool>);
969
970impl Drop for ProcessCancellationGuard {
971 fn drop(&mut self) {
972 self.0.store(true, Ordering::Release);
973 }
974}
975
976impl CancellableProcessExecutor {
977 pub fn cancel_on_drop(&self) -> ProcessCancellationGuard {
978 ProcessCancellationGuard(self.cancelled.clone())
979 }
980
981 pub fn new(cancelled: Arc<AtomicBool>) -> Self {
982 Self {
983 cancelled,
984 deadline: None,
985 }
986 }
987
988 pub fn is_cancelled(&self) -> bool {
989 self.cancelled.load(Ordering::Acquire)
990 || self
991 .deadline
992 .is_some_and(|deadline| Instant::now() >= deadline)
993 }
994
995 pub fn with_timeout(timeout: Duration) -> Self {
996 Self {
997 cancelled: Arc::new(AtomicBool::new(false)),
998 deadline: Some(Instant::now() + timeout),
999 }
1000 }
1001
1002 pub fn with_deadline(mut self, timeout: Duration) -> Self {
1005 self.deadline = Some(Instant::now() + timeout);
1006 self
1007 }
1008
1009 fn check_cancelled(&self) -> Result<()> {
1010 if self.is_cancelled() {
1011 bail!("operation cancelled");
1012 }
1013 Ok(())
1014 }
1015}
1016
1017fn configured_command(command: &CommandSpec) -> Command {
1018 let mut process = Command::new(&command.program);
1019 if command.clear_env {
1020 process.env_clear();
1021 }
1022 if let Some(cwd) = &command.cwd {
1023 process.current_dir(cwd);
1024 }
1025 process.args(&command.args).envs(&command.env);
1026 process
1027}
1028
1029fn cancellable_command(command: &CommandSpec) -> Command {
1030 #[cfg(unix)]
1031 let mut process = configured_command(command);
1032 #[cfg(not(unix))]
1033 let process = configured_command(command);
1034 #[cfg(unix)]
1035 {
1036 use std::os::unix::process::CommandExt as _;
1037 process.process_group(0);
1038 }
1039 process
1040}
1041
1042const IO_DRAIN_TIMEOUT: Duration = Duration::from_secs(2);
1048
1049trait PollablePipe: Read + Send + 'static {
1052 fn readable(&self, timeout: Duration) -> bool;
1053}
1054
1055macro_rules! pollable_pipe {
1056 ($pipe:ty) => {
1057 impl PollablePipe for $pipe {
1058 #[cfg(unix)]
1059 fn readable(&self, timeout: Duration) -> bool {
1060 use std::os::fd::AsRawFd as _;
1061 let mut poll = libc::pollfd {
1062 fd: self.as_raw_fd(),
1063 events: libc::POLLIN,
1064 revents: 0,
1065 };
1066 let millis = i32::try_from(timeout.as_millis()).unwrap_or(i32::MAX);
1067 unsafe { libc::poll(&raw mut poll, 1, millis) > 0 }
1069 }
1070
1071 #[cfg(not(unix))]
1073 fn readable(&self, _timeout: Duration) -> bool {
1074 true
1075 }
1076 }
1077 };
1078}
1079
1080pollable_pipe!(std::process::ChildStdout);
1081pollable_pipe!(std::process::ChildStderr);
1082
1083struct PipeCollector {
1087 bytes: Arc<std::sync::Mutex<Vec<u8>>>,
1088 stop: Arc<AtomicBool>,
1089 thread: Option<std::thread::JoinHandle<std::io::Result<()>>>,
1090}
1091
1092impl PipeCollector {
1093 fn spawn(mut pipe: impl PollablePipe) -> Self {
1094 let bytes = Arc::new(std::sync::Mutex::new(Vec::new()));
1095 let stop = Arc::new(AtomicBool::new(false));
1096 let (collected, stopped) = (bytes.clone(), stop.clone());
1097 let thread = std::thread::spawn(move || {
1098 let mut chunk = [0_u8; 8192];
1099 while !stopped.load(Ordering::Acquire) {
1100 if !pipe.readable(Duration::from_millis(25)) {
1101 continue;
1102 }
1103 match pipe.read(&mut chunk) {
1104 Ok(0) => break,
1105 Ok(count) => collected
1106 .lock()
1107 .unwrap_or_else(std::sync::PoisonError::into_inner)
1108 .extend_from_slice(&chunk[..count]),
1109 Err(error) if error.kind() == std::io::ErrorKind::Interrupted => {}
1110 Err(error) => return Err(error),
1111 }
1112 }
1113 Ok(())
1114 });
1115 Self {
1116 bytes,
1117 stop,
1118 thread: Some(thread),
1119 }
1120 }
1121
1122 fn is_finished(&self) -> bool {
1123 self.thread
1124 .as_ref()
1125 .is_none_or(std::thread::JoinHandle::is_finished)
1126 }
1127
1128 fn finish(mut self, stream: &str, deadline: Instant) -> Result<Vec<u8>> {
1131 while !self.is_finished() && Instant::now() < deadline {
1132 std::thread::sleep(Duration::from_millis(5));
1133 }
1134 self.stop.store(true, Ordering::Release);
1135 self.thread
1136 .take()
1137 .context("command reader already joined")?
1138 .join()
1139 .map_err(|_| anyhow::anyhow!("command {stream} reader panicked"))?
1140 .with_context(|| format!("read command {stream}"))?;
1141 let mut bytes = self
1142 .bytes
1143 .lock()
1144 .unwrap_or_else(std::sync::PoisonError::into_inner);
1145 Ok(std::mem::take(&mut bytes))
1146 }
1147}
1148
1149impl Drop for PipeCollector {
1150 fn drop(&mut self) {
1151 self.stop.store(true, Ordering::Release);
1152 }
1153}
1154
1155fn terminate_cancellable_child(child: &mut std::process::Child, preserve_children: bool) {
1156 #[cfg(unix)]
1157 if preserve_children {
1158 if let Err(error) = child.kill()
1159 && error.kind() != std::io::ErrorKind::NotFound
1160 {
1161 tracing::warn!(pid = child.id(), %error, "could not terminate cancelled command");
1162 }
1163 } else if let Err(error) =
1164 crate::subprocess::signal_process_group(child.id() as i32, libc::SIGKILL)
1165 {
1166 tracing::warn!(pid = child.id(), %error, "could not terminate cancelled command process group");
1168 }
1169 #[cfg(not(unix))]
1170 {
1171 let _ = preserve_children;
1172 if let Err(error) = child.kill() {
1173 tracing::warn!(pid = child.id(), %error, "could not terminate cancelled command");
1174 }
1175 }
1176 if let Err(error) = child.wait() {
1177 tracing::warn!(pid = child.id(), %error, "could not reap cancelled command");
1178 }
1179}
1180
1181impl CancellableProcessExecutor {
1182 fn run_once(&self, command: &CommandSpec) -> Result<CommandOutput> {
1184 if let Some(input) = &command.sensitive_stdin {
1185 let mut input = std::io::Cursor::new(input.0.as_slice());
1186 return stream_command_with_stdin(
1188 cancellable_command(command),
1189 command,
1190 &mut input,
1191 &|| self.is_cancelled(),
1192 );
1193 }
1194 let started = Instant::now();
1195 self.check_cancelled()?;
1196 let mut child = cancellable_command(command)
1197 .stdin(Stdio::null())
1198 .stdout(Stdio::piped())
1199 .stderr(Stdio::piped())
1200 .spawn()
1201 .with_context(|| format!("run {} for {}", command.program, command.purpose))?;
1202 let group = (!command.detaches && !command.preserve_children_on_cancel)
1203 .then(|| crate::subprocess::ProcessGroupGuard::new(Some(child.id())));
1204 let stdout = child.stdout.take().context("command stdout missing")?;
1205 let stderr = child.stderr.take().context("command stderr missing")?;
1206 let stdout_reader = PipeCollector::spawn(stdout);
1207 let stderr_reader = PipeCollector::spawn(stderr);
1208 let mut status = None;
1209 let mut exited_at = None;
1210 let status = loop {
1211 if self.is_cancelled() {
1212 terminate_cancellable_child(&mut child, command.preserve_children_on_cancel);
1213 let deadline = Instant::now() + IO_DRAIN_TIMEOUT;
1214 for (stream, reader) in [("stdout", stdout_reader), ("stderr", stderr_reader)] {
1215 if let Err(error) = reader.finish(stream, deadline) {
1216 tracing::warn!(stream, %error, "cancelled command reader failed");
1217 }
1218 }
1219 bail!("operation cancelled while {}", command.purpose);
1220 }
1221 if status.is_none() {
1222 status = child
1223 .try_wait()
1224 .with_context(|| format!("wait for {}", command.purpose))?;
1225 }
1226 if let Some(status) = status {
1229 let exited_at = *exited_at.get_or_insert_with(Instant::now);
1230 if (stdout_reader.is_finished() && stderr_reader.is_finished())
1231 || exited_at.elapsed() >= IO_DRAIN_TIMEOUT
1232 {
1233 break status;
1234 }
1235 }
1236 std::thread::sleep(Duration::from_millis(25));
1237 };
1238 let deadline = Instant::now();
1239 let stdout = stdout_reader.finish("stdout", deadline)?;
1240 let stderr = stderr_reader.finish("stderr", deadline)?;
1241 let status = status.code().unwrap_or(-1);
1242 drop(group);
1243 Ok(finished_command(command, started, status, stdout, stderr))
1244 }
1245}
1246
1247impl CommandExecutor for CancellableProcessExecutor {
1248 fn execute_cleanup(&self, command: &CommandSpec) -> Result<CommandOutput> {
1249 self.clone()
1250 .with_deadline(Duration::from_secs(15))
1251 .execute(command)
1252 }
1253
1254 fn execute_cleanup_with_timeout(
1255 &self,
1256 command: &CommandSpec,
1257 timeout: Duration,
1258 ) -> Result<CommandOutput> {
1259 self.clone().with_deadline(timeout).execute(command)
1260 }
1261
1262 fn cancellation_requested(&self) -> bool {
1263 self.is_cancelled()
1264 }
1265
1266 fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
1267 crate::runtime::off_async_worker(|| {
1268 let _running = BlockingOperation::command(command);
1269 with_ssh_admission(command, self, &|| self.is_cancelled(), |command| {
1270 self.run_once(command)
1271 })
1272 })
1273 }
1274
1275 fn execute_with_stdin(
1276 &self,
1277 command: &CommandSpec,
1278 input: &mut (dyn Read + Send),
1279 ) -> Result<CommandOutput> {
1280 crate::runtime::off_async_worker(|| {
1281 let _running = BlockingOperation::command(command);
1282 let session = command.open_ssh_session(self)?;
1285 let _permit = command
1286 .ssh_destination
1287 .as_deref()
1288 .map(|destination| {
1289 SshAdmission::acquire_unless(destination, &|| self.is_cancelled())
1290 })
1291 .transpose()?;
1292 let command = session.command();
1293 stream_command_with_stdin(cancellable_command(command), command, input, &|| {
1297 self.is_cancelled()
1298 })
1299 })
1300 }
1301}
1302
1303#[derive(Debug, Clone, PartialEq, Eq)]
1307pub struct CommandTimedOut {
1308 pub program: String,
1309 pub purpose: String,
1310 pub timeout: Duration,
1311}
1312
1313impl std::fmt::Display for CommandTimedOut {
1314 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1315 write!(
1316 formatter,
1317 "`{}` did not answer within {} seconds while trying to {}",
1318 self.program,
1319 self.timeout.as_secs(),
1320 self.purpose
1321 )
1322 }
1323}
1324
1325impl std::error::Error for CommandTimedOut {}
1326
1327#[derive(Debug, Clone, Copy)]
1336pub struct BoundedProcessExecutor {
1337 timeout: Duration,
1338}
1339
1340impl BoundedProcessExecutor {
1341 pub const fn new(timeout: Duration) -> Self {
1342 Self { timeout }
1343 }
1344}
1345
1346impl CommandExecutor for BoundedProcessExecutor {
1347 fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
1348 let executor = CancellableProcessExecutor::with_timeout(self.timeout);
1349 executor.execute(command).map_err(|error| {
1350 if executor.is_cancelled() {
1351 anyhow::Error::new(CommandTimedOut {
1352 program: command.program.clone(),
1353 purpose: command.purpose.clone(),
1354 timeout: self.timeout,
1355 })
1356 } else {
1357 error
1358 }
1359 })
1360 }
1361
1362 fn execute_cleanup_with_timeout(
1363 &self,
1364 command: &CommandSpec,
1365 timeout: Duration,
1366 ) -> Result<CommandOutput> {
1367 CancellableProcessExecutor::with_timeout(self.timeout.min(timeout)).execute(command)
1370 }
1371
1372 fn execute_with_stdin(
1373 &self,
1374 command: &CommandSpec,
1375 input: &mut (dyn Read + Send),
1376 ) -> Result<CommandOutput> {
1377 CancellableProcessExecutor::with_timeout(self.timeout).execute_with_stdin(command, input)
1378 }
1379}
1380
1381#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1382pub struct CommandPlan {
1383 pub description: String,
1384 pub commands: Vec<CommandSpec>,
1385}
1386
1387impl CommandPlan {
1388 pub fn provide_target_environment_secret(
1392 &mut self,
1393 target: &TargetTemplate,
1394 name: &str,
1395 value: &str,
1396 ) -> Result<()> {
1397 ensure!(
1398 !name.is_empty()
1399 && name.bytes().enumerate().all(|(index, byte)| byte == b'_'
1400 || byte.is_ascii_alphabetic()
1401 || (index > 0 && byte.is_ascii_digit())),
1402 "invalid secret environment variable name"
1403 );
1404 ensure!(
1405 !value.as_bytes().contains(&b'\n') && !value.as_bytes().contains(&b'\r'),
1406 "secret environment value cannot contain a newline"
1407 );
1408 let command = self
1409 .commands
1410 .iter_mut()
1411 .find(|command| command.creates_target)
1412 .context("provisioning plan has no target creation command")?;
1413 let read_and_export = format!("IFS= read -r {name} || exit 1; export {name};");
1414 match target {
1415 TargetTemplate::LocalPodman(_)
1416 | TargetTemplate::LocalDocker(_)
1417 | TargetTemplate::AppleContainer(_) => {
1418 let program = std::mem::replace(&mut command.program, "sh".to_owned());
1419 let args = std::mem::take(&mut command.args);
1420 command.args = vec![
1421 "-c".to_owned(),
1422 format!("{read_and_export} exec \"$@\""),
1423 "mj-secret-env".to_owned(),
1424 program,
1425 ];
1426 command.args.extend(args);
1427 }
1428 TargetTemplate::SshPodman { .. } | TargetTemplate::SshDocker { .. } => {
1429 let remote = command
1430 .args
1431 .last_mut()
1432 .context("remote container command has no SSH command argument")?;
1433 *remote = format!("{read_and_export} exec {remote}");
1434 }
1435 TargetTemplate::LocalBare
1436 | TargetTemplate::AwsEc2(_)
1437 | TargetTemplate::SshBare { .. } => {
1438 bail!("target does not support inherited container environment")
1439 }
1440 }
1441 let mut input = value.as_bytes().to_vec();
1442 input.push(b'\n');
1443 command.sensitive_stdin = Some(SensitiveCommandInput(input));
1444 Ok(())
1445 }
1446
1447 pub fn execute(&self, executor: &impl CommandExecutor) -> Result<Vec<CommandOutput>> {
1448 let mut outputs = Vec::with_capacity(self.commands.len());
1449 for command in &self.commands {
1450 let output = executor.execute(command)?;
1451 if output.status != 0 {
1452 bail!(
1453 "{} failed with status {}: {}",
1454 command.purpose,
1455 output.status,
1456 String::from_utf8_lossy(&output.stderr)
1457 );
1458 }
1459 outputs.push(output);
1460 }
1461 Ok(outputs)
1462 }
1463
1464 pub fn execute_concurrent(
1476 &self,
1477 executor: &(impl CommandExecutor + Sync),
1478 ) -> Result<Vec<CommandOutput>> {
1479 let mut outputs = Vec::with_capacity(self.commands.len());
1480 let mut index = 0;
1481 while index < self.commands.len() {
1482 let group = self.commands[index].parallel_group;
1483 let mut end = index + 1;
1484 if group.is_some() {
1485 while end < self.commands.len() && self.commands[end].parallel_group == group {
1486 end += 1;
1487 }
1488 }
1489 let batch = &self.commands[index..end];
1490 if let [command] = batch {
1491 outputs.push(checked_command_output(command, executor.execute(command)?)?);
1492 } else {
1493 let results: Vec<Result<CommandOutput>> = std::thread::scope(|scope| {
1494 let handles: Vec<_> = batch
1495 .iter()
1496 .map(|command| scope.spawn(|| executor.execute(command)))
1497 .collect();
1498 handles
1499 .into_iter()
1500 .map(|handle| match handle.join() {
1501 Ok(result) => result,
1502 Err(panic) => Err(anyhow::anyhow!(
1503 "concurrent command thread panicked: {}",
1504 command_thread_panic_message(panic.as_ref())
1505 )),
1506 })
1507 .collect()
1508 });
1509 for (command, result) in batch.iter().zip(results) {
1510 outputs.push(checked_command_output(command, result?)?);
1511 }
1512 }
1513 index = end;
1514 }
1515 Ok(outputs)
1516 }
1517
1518 pub fn split_at_target_creation(&self) -> Option<(Self, Self)> {
1526 let created = self
1527 .commands
1528 .iter()
1529 .position(|command| command.creates_target)?;
1530 let (creation, remainder) = self.commands.split_at(created + 1);
1531 Some((
1532 Self {
1533 description: self.description.clone(),
1534 commands: creation.to_vec(),
1535 },
1536 Self {
1537 description: self.description.clone(),
1538 commands: remainder.to_vec(),
1539 },
1540 ))
1541 }
1542}
1543
1544pub fn checked_command_output(
1548 command: &CommandSpec,
1549 output: CommandOutput,
1550) -> Result<CommandOutput> {
1551 if output.status != 0 {
1552 bail!(
1553 "{} failed with status {}: {}",
1554 command.purpose,
1555 output.status,
1556 String::from_utf8_lossy(&output.stderr)
1557 );
1558 }
1559 Ok(output)
1560}
1561
1562pub fn command_thread_panic_message(payload: &(dyn std::any::Any + Send)) -> String {
1564 if let Some(message) = payload.downcast_ref::<&str>() {
1565 (*message).to_owned()
1566 } else if let Some(message) = payload.downcast_ref::<String>() {
1567 message.clone()
1568 } else {
1569 "non-string panic payload".to_owned()
1570 }
1571}
1572
1573#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1574pub struct RepositorySpec {
1575 pub url: Option<String>,
1577 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1578 pub push_urls: Vec<String>,
1579 pub destination: String,
1580 pub git_ref: Option<String>,
1581 #[serde(default, skip_serializing_if = "Option::is_none")]
1584 pub reference: Option<String>,
1585}
1586
1587#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1588pub struct ProjectBundleSpec {
1589 pub primary: String,
1590 pub repositories: Vec<RepositorySpec>,
1591}
1592
1593impl ProjectBundleSpec {
1594 pub fn validate(&self) -> Result<()> {
1595 validate_relative_path(&self.primary)?;
1596 if self.repositories.is_empty() {
1597 bail!("a project bundle must contain at least one repository");
1598 }
1599 let mut destinations = std::collections::BTreeSet::new();
1600 for repository in &self.repositories {
1601 validate_relative_path(&repository.destination)?;
1602 ensure!(
1603 repository
1604 .url
1605 .as_deref()
1606 .is_some_and(|url| !url.trim().is_empty() && !url.starts_with('-')),
1607 "isolated repositories require a network Git remote; configure a remote or use a raw local session"
1608 );
1609 crate::remote_git::validate_network_url(
1610 repository.url.as_deref().expect("checked above"),
1611 )?;
1612 for push_url in &repository.push_urls {
1613 crate::remote_git::validate_network_url(push_url)?;
1614 }
1615 ensure!(
1616 repository.git_ref.is_none(),
1617 "git_ref is no longer supported; remove it to start from the remote's default branch"
1618 );
1619 if !destinations.insert(&repository.destination) {
1620 bail!(
1621 "duplicate repository destination {}",
1622 repository.destination
1623 );
1624 }
1625 }
1626 if !destinations.contains(&self.primary) {
1627 bail!("primary repository is not present in the bundle");
1628 }
1629 Ok(())
1630 }
1631}
1632
1633#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1634#[serde(tag = "kind", rename_all = "snake_case")]
1635pub enum PodmanWorkspaceStorage {
1636 PodmanVolume,
1637 HostHelper {
1638 root: String,
1639 helper: Vec<String>,
1640 },
1641 #[default]
1642 ContainerLayer,
1643}
1644
1645#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1646pub struct ContainerTemplate {
1647 pub image: String,
1648 #[serde(default)]
1649 pub pull_policy: ImagePullPolicy,
1650 #[serde(default)]
1651 pub extra_run_args: Vec<String>,
1652 #[serde(default)]
1653 pub workspace_storage: PodmanWorkspaceStorage,
1654 #[serde(default)]
1657 pub build_cache: Option<crate::config::TargetBuildCache>,
1658}
1659
1660impl ImagePullPolicy {
1661 pub fn resolve(self, image: &str) -> Self {
1664 if self != Self::Auto {
1665 return self;
1666 }
1667 if image_is_digest_pinned(image) {
1668 Self::Missing
1669 } else if image_is_remote(image) && image_uses_latest_tag(image) {
1670 Self::Newer
1671 } else {
1672 Self::Missing
1673 }
1674 }
1675
1676 pub fn at_launch(self, image: &str) -> Self {
1681 if self == Self::Auto {
1682 Self::Missing
1683 } else {
1684 self.resolve(image)
1685 }
1686 }
1687
1688 pub fn describe(self, image: &str) -> &'static str {
1693 match self {
1694 Self::Always => "Pull every launch",
1695 Self::Newer => "Pull when the registry is newer",
1696 Self::Missing => "Pull only if missing",
1697 Self::Never => "Never pull",
1698 Self::Auto => match self.resolve(image) {
1699 Self::Newer => "Pull if missing at launch; refresh :latest in background",
1700 _ => "Pull if missing",
1701 },
1702 }
1703 }
1704
1705 pub fn podman_value(self) -> &'static str {
1707 match self {
1708 Self::Always => "always",
1709 Self::Newer => "newer",
1710 Self::Missing => "missing",
1711 Self::Never => "never",
1712 Self::Auto => unreachable!("auto pull policy must resolve"),
1713 }
1714 }
1715}
1716
1717#[derive(Debug, Clone, PartialEq, Eq)]
1723pub enum ImageHost {
1724 LocalPodman,
1725 LocalDocker,
1726 AppleContainer,
1727 SshPodman(SshTarget),
1728 SshDocker(SshTarget),
1729}
1730
1731impl ImageHost {
1732 pub const fn engine(&self) -> &'static str {
1733 match self {
1734 Self::LocalPodman | Self::SshPodman(_) => "podman",
1735 Self::LocalDocker | Self::SshDocker(_) => "docker",
1736 Self::AppleContainer => "container",
1737 }
1738 }
1739
1740 pub fn label(&self) -> String {
1742 match self {
1743 Self::LocalPodman => "local podman".to_owned(),
1744 Self::LocalDocker => "local docker".to_owned(),
1745 Self::AppleContainer => "apple container".to_owned(),
1746 Self::SshPodman(ssh) => format!("podman on {}", ssh.destination),
1747 Self::SshDocker(ssh) => format!("docker on {}", ssh.destination),
1748 }
1749 }
1750
1751 fn command(&self, args: Vec<String>, purpose: String) -> CommandSpec {
1752 match self {
1753 Self::LocalPodman | Self::LocalDocker | Self::AppleContainer => {
1754 CommandSpec::new(args[0].clone(), args[1..].iter().cloned())
1755 }
1756 Self::SshPodman(ssh) | Self::SshDocker(ssh) => ssh_command_owned(ssh, args),
1757 }
1758 .purpose(purpose)
1759 }
1760}
1761
1762#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
1769pub enum RefreshWhen {
1770 WhenAbsent,
1772 Always,
1774}
1775
1776#[derive(Debug, Clone, PartialEq, Eq)]
1779pub struct ImageRefresh {
1780 pub host: ImageHost,
1781 pub image: String,
1782 pub platform: Option<String>,
1783 pub when: RefreshWhen,
1786 pub image_id: CommandSpec,
1789 pub pull: CommandSpec,
1790 pub prune: Option<CommandSpec>,
1793}
1794
1795pub fn image_refresh(
1802 host: ImageHost,
1803 image: &str,
1804 platform: Option<&str>,
1805 pull_policy: ImagePullPolicy,
1806) -> Option<ImageRefresh> {
1807 let when = match pull_policy.resolve(image) {
1808 ImagePullPolicy::Always | ImagePullPolicy::Newer => RefreshWhen::Always,
1809 ImagePullPolicy::Missing => RefreshWhen::WhenAbsent,
1810 ImagePullPolicy::Never => return None,
1811 ImagePullPolicy::Auto => unreachable!("auto pull policy must resolve"),
1812 };
1813 let engine = host.engine();
1814 let apple = matches!(host, ImageHost::AppleContainer);
1818 let mut image_id_args = vec![engine.to_owned(), "image".to_owned(), "inspect".to_owned()];
1819 if !apple {
1820 image_id_args.push("--format".to_owned());
1821 image_id_args.push("{{.Id}}".to_owned());
1822 }
1823 image_id_args.push(image.to_owned());
1824 let image_id = host.command(
1825 image_id_args,
1826 format!("read the cached id of container image {image}"),
1827 );
1828 let mut pull_args = vec![engine.to_owned()];
1829 if apple {
1830 pull_args.push("image".to_owned());
1831 }
1832 pull_args.push("pull".to_owned());
1833 if let Some(platform) = platform.filter(|_| !apple) {
1835 pull_args.push(format!("--platform={platform}"));
1836 }
1837 pull_args.push(image.to_owned());
1838 let pull = host.command(pull_args, format!("refresh container image {image}"));
1839 let prune = (!apple).then(|| {
1840 host.command(
1841 vec![
1842 engine.to_owned(),
1843 "image".to_owned(),
1844 "prune".to_owned(),
1845 "-f".to_owned(),
1846 ],
1847 "remove dangling container images".to_owned(),
1848 )
1849 });
1850 Some(ImageRefresh {
1851 host,
1852 image: image.to_owned(),
1853 platform: platform.map(str::to_owned),
1854 when,
1855 image_id,
1856 pull,
1857 prune,
1858 })
1859}
1860
1861fn image_is_digest_pinned(image: &str) -> bool {
1862 image
1863 .rsplit_once('@')
1864 .is_some_and(|(_, digest)| !digest.is_empty())
1865}
1866
1867fn image_is_remote(image: &str) -> bool {
1868 !image.starts_with("localhost/") && !image.starts_with("local/")
1869}
1870
1871fn image_uses_latest_tag(image: &str) -> bool {
1872 let name = image.split_once('@').map_or(image, |(name, _)| name);
1873 let final_component = name.rsplit('/').next().unwrap_or(name);
1874 !final_component.contains(':') || final_component.ends_with(":latest")
1875}
1876
1877#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1878pub struct SshTarget {
1879 pub destination: String,
1880 #[serde(default)]
1881 pub ssh_args: Vec<String>,
1882}
1883
1884#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1885pub struct AwsTemplate {
1886 pub profile: String,
1887 pub region: String,
1888 pub launch_template: String,
1889 pub launch_template_version: Option<String>,
1890 pub instance_type: Option<String>,
1891 pub ssh: SshTarget,
1892}
1893
1894#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1895#[serde(tag = "kind", rename_all = "snake_case")]
1896pub enum TargetTemplate {
1897 LocalBare,
1898 LocalPodman(ContainerTemplate),
1899 LocalDocker(ContainerTemplate),
1900 AppleContainer(ContainerTemplate),
1901 AwsEc2(AwsTemplate),
1902 SshBare {
1903 ssh: SshTarget,
1904 #[serde(default = "default_ssh_prefix")]
1905 workspace_prefix: String,
1906 },
1907 SshPodman {
1908 ssh: SshTarget,
1909 container: ContainerTemplate,
1910 },
1911 SshDocker {
1912 ssh: SshTarget,
1913 container: ContainerTemplate,
1914 },
1915}
1916
1917fn default_ssh_prefix() -> String {
1918 ".local/share/hel/workspaces".to_owned()
1919}
1920
1921#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1922#[serde(tag = "kind", rename_all = "snake_case")]
1923pub enum PodmanWorkspaceLocator {
1924 #[default]
1925 ContainerLayer,
1926 Volume {
1927 name: String,
1928 },
1929 HostPath {
1930 path: String,
1931 helper: Vec<String>,
1932 resource: String,
1933 },
1934}
1935
1936#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1937#[serde(tag = "kind", rename_all = "snake_case")]
1938pub enum TargetLocator {
1939 LocalBare {
1940 worker_root: String,
1941 },
1942 LocalPodman {
1943 container_id: String,
1944 #[serde(default)]
1945 workspace_storage: PodmanWorkspaceLocator,
1946 #[serde(default, skip_serializing_if = "Option::is_none")]
1950 borrowed_from: Option<String>,
1951 },
1952 LocalDocker {
1953 container_id: String,
1954 #[serde(default, skip_serializing_if = "Option::is_none")]
1958 borrowed_from: Option<String>,
1959 },
1960 AppleContainer {
1961 container_id: String,
1962 #[serde(default, skip_serializing_if = "Option::is_none")]
1966 borrowed_from: Option<String>,
1967 },
1968 AwsEc2 {
1969 profile: String,
1970 region: String,
1971 instance_id: String,
1972 ssh: SshTarget,
1973 workspace: String,
1974 },
1975 SshBare {
1976 ssh: SshTarget,
1977 workspace: String,
1978 #[serde(default, skip_serializing_if = "Option::is_none")]
1980 worker_id: Option<String>,
1981 },
1982 SshPodman {
1983 ssh: SshTarget,
1984 container_id: String,
1985 #[serde(default)]
1986 workspace_storage: PodmanWorkspaceLocator,
1987 #[serde(default, skip_serializing_if = "Option::is_none")]
1991 borrowed_from: Option<String>,
1992 },
1993 SshDocker {
1994 ssh: SshTarget,
1995 container_id: String,
1996 #[serde(default, skip_serializing_if = "Option::is_none")]
2000 borrowed_from: Option<String>,
2001 },
2002}
2003
2004impl TargetTemplate {
2005 pub const fn container_engine(&self) -> Option<&'static str> {
2006 match self {
2007 Self::LocalPodman(_) | Self::SshPodman { .. } => Some("podman"),
2008 Self::LocalDocker(_) | Self::SshDocker { .. } => Some("docker"),
2009 Self::AppleContainer(_) => Some("container"),
2010 _ => None,
2011 }
2012 }
2013
2014 pub fn image_host(&self) -> Option<(ImageHost, &ContainerTemplate)> {
2017 match self {
2018 Self::LocalPodman(container) => Some((ImageHost::LocalPodman, container)),
2019 Self::LocalDocker(container) => Some((ImageHost::LocalDocker, container)),
2020 Self::AppleContainer(container) => Some((ImageHost::AppleContainer, container)),
2021 Self::SshPodman { ssh, container } => {
2022 Some((ImageHost::SshPodman(ssh.clone()), container))
2023 }
2024 Self::SshDocker { ssh, container } => {
2025 Some((ImageHost::SshDocker(ssh.clone()), container))
2026 }
2027 Self::LocalBare | Self::AwsEc2(_) | Self::SshBare { .. } => None,
2028 }
2029 }
2030}
2031
2032impl TargetLocator {
2033 pub const fn kind_name(&self) -> &'static str {
2035 match self {
2036 Self::LocalBare { .. } => "local-bare",
2037 Self::LocalPodman { .. } => "local-podman",
2038 Self::LocalDocker { .. } => "local-docker",
2039 Self::AppleContainer { .. } => "apple-container",
2040 Self::AwsEc2 { .. } => "aws-ec2",
2041 Self::SshBare { .. } => "ssh-bare",
2042 Self::SshPodman { .. } => "ssh-podman",
2043 Self::SshDocker { .. } => "ssh-docker",
2044 }
2045 }
2046
2047 pub const fn container_engine(&self) -> Option<&'static str> {
2048 match self {
2049 Self::LocalPodman { .. } | Self::SshPodman { .. } => Some("podman"),
2050 Self::LocalDocker { .. } | Self::SshDocker { .. } => Some("docker"),
2051 Self::AppleContainer { .. } => Some("container"),
2052 _ => None,
2053 }
2054 }
2055}
2056
2057#[derive(Debug, Clone, PartialEq, Eq)]
2061pub struct TargetRecoveryPlan {
2062 pub exists: CommandSpec,
2063 pub inspect: CommandSpec,
2064 pub start: CommandSpec,
2065 pub session_id: String,
2066}
2067
2068#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2069pub enum TargetRecoveryOutcome {
2070 NotRequired,
2071 Missing,
2072 AlreadyRunning,
2073 Started,
2074}
2075
2076pub fn resource_name(session_id: &str) -> Result<String> {
2077 validate_session_id(session_id)?;
2078 let readable: String = session_id
2079 .chars()
2080 .filter(|character| character.is_ascii_alphanumeric())
2081 .take(12)
2082 .map(|character| character.to_ascii_lowercase())
2083 .collect();
2084 let digest = Sha256::digest(session_id.as_bytes());
2085 Ok(format!(
2086 "mj-{readable}-{:02x}{:02x}{:02x}",
2087 digest[0], digest[1], digest[2]
2088 ))
2089}
2090
2091pub fn move_resource_name(session_id: &str, operation_id: &str) -> Result<String> {
2093 let digest = Sha256::digest(operation_id.as_bytes());
2094 Ok(format!(
2095 "{}-move-{}",
2096 resource_name(session_id)?,
2097 crate::hex::lower_hex(&digest[..8])
2098 ))
2099}
2100
2101pub fn resource_name_belongs_to(name: &str, session_id: &str) -> Result<bool> {
2102 let base = resource_name(session_id)?;
2103 Ok(name == base
2104 || name
2105 .strip_prefix(&format!("{base}-move-"))
2106 .is_some_and(|suffix| {
2107 suffix.len() == 16 && suffix.bytes().all(|b| b.is_ascii_hexdigit())
2108 }))
2109}
2110
2111pub fn podman_workspace_locator(
2112 template: &ContainerTemplate,
2113 session_id: &str,
2114) -> Result<PodmanWorkspaceLocator> {
2115 podman_workspace_locator_named(template, &resource_name(session_id)?)
2116}
2117
2118pub fn podman_workspace_locator_named(
2119 template: &ContainerTemplate,
2120 name: &str,
2121) -> Result<PodmanWorkspaceLocator> {
2122 let resource = format!("{name}-workspace");
2123 match &template.workspace_storage {
2124 PodmanWorkspaceStorage::PodmanVolume => {
2125 Ok(PodmanWorkspaceLocator::Volume { name: resource })
2126 }
2127 PodmanWorkspaceStorage::HostHelper { root, helper } => {
2128 let root = Path::new(root);
2129 ensure!(
2130 crate::target_path::is_absolute(root),
2131 "Podman workspace storage root must be absolute"
2132 );
2133 ensure!(
2134 !helper.is_empty() && helper.iter().all(|argument| !argument.is_empty()),
2135 "Podman workspace storage helper must contain non-empty arguments"
2136 );
2137 Ok(PodmanWorkspaceLocator::HostPath {
2138 path: root.join(&resource).to_string_lossy().into_owned(),
2139 helper: helper.clone(),
2140 resource,
2141 })
2142 }
2143 PodmanWorkspaceStorage::ContainerLayer => Ok(PodmanWorkspaceLocator::ContainerLayer),
2144 }
2145}
2146
2147pub fn container_workspace_root(recorded: Option<&Path>) -> String {
2155 recorded.map_or_else(
2156 || CONTAINER_WORKSPACE.to_owned(),
2157 |path| path.to_string_lossy().into_owned(),
2158 )
2159}
2160
2161pub fn new_container_workspace(session_id: &str) -> Result<PathBuf> {
2163 validate_session_id(session_id)?;
2164 Ok(Path::new(CONTAINER_WORKSPACE).join(session_id))
2165}
2166
2167pub fn aws_workspace(session_id: &str) -> String {
2173 format!(".local/share/hel/workspaces/{session_id}")
2174}
2175
2176pub fn workspace_for(template: &TargetTemplate, session_id: &str) -> Result<String> {
2177 validate_session_id(session_id)?;
2178 match template {
2179 TargetTemplate::LocalBare => bail!("local bare projects use their selected directory"),
2180 TargetTemplate::LocalPodman(_)
2183 | TargetTemplate::LocalDocker(_)
2184 | TargetTemplate::AppleContainer(_)
2185 | TargetTemplate::SshPodman { .. }
2186 | TargetTemplate::SshDocker { .. } => {
2187 bail!("container targets use the session's recorded container workspace")
2188 }
2189 TargetTemplate::AwsEc2(_) => Ok(aws_workspace(session_id)),
2190 TargetTemplate::SshBare {
2191 workspace_prefix, ..
2192 } => {
2193 validate_workspace_prefix(workspace_prefix)?;
2194 let prefix = workspace_prefix
2199 .strip_prefix("~/")
2200 .unwrap_or(workspace_prefix);
2201 Ok(format!("{}/{session_id}", prefix.trim_end_matches('/')))
2202 }
2203 }
2204}
2205
2206pub fn command_on_locator(
2208 locator: &TargetLocator,
2209 session_id: &str,
2210 args: Vec<String>,
2211 purpose: impl Into<String>,
2212) -> Result<CommandSpec> {
2213 verify_locator(locator, session_id)?;
2214 if args.is_empty() {
2215 bail!("target command must not be empty");
2216 }
2217 Ok(locator_command(locator, args).purpose(purpose))
2218}
2219
2220pub fn locator_command(locator: &TargetLocator, args: Vec<String>) -> CommandSpec {
2224 match locator {
2225 TargetLocator::LocalBare { .. } => {
2226 let mut args = args.into_iter();
2227 let program = args.next().expect("target command must not be empty");
2228 CommandSpec::new(program, args)
2229 }
2230 TargetLocator::LocalPodman { container_id, .. }
2231 | TargetLocator::LocalDocker { container_id, .. }
2232 | TargetLocator::AppleContainer { container_id, .. } => container_exec(
2233 locator.container_engine().expect("local container"),
2234 container_id,
2235 args,
2236 ),
2237 TargetLocator::AwsEc2 { ssh, .. } | TargetLocator::SshBare { ssh, .. } => {
2238 ssh_command_owned(ssh, args)
2239 }
2240 TargetLocator::SshPodman {
2241 ssh, container_id, ..
2242 }
2243 | TargetLocator::SshDocker {
2244 ssh, container_id, ..
2245 } => {
2246 let mut remote = vec![
2247 locator
2248 .container_engine()
2249 .expect("remote container")
2250 .to_owned(),
2251 "exec".to_owned(),
2252 "-i".to_owned(),
2253 container_id.to_owned(),
2254 ];
2255 remote.extend(args);
2256 ssh_command_owned(ssh, remote)
2257 }
2258 }
2259}
2260pub fn worker_root(locator: &TargetLocator, session_id: &str) -> Result<String> {
2261 verify_locator(locator, session_id)?;
2262 Ok(match locator {
2263 TargetLocator::LocalBare { worker_root } => worker_root.clone(),
2264 TargetLocator::LocalPodman { .. }
2265 | TargetLocator::LocalDocker { .. }
2266 | TargetLocator::AppleContainer { .. }
2267 | TargetLocator::SshPodman { .. }
2268 | TargetLocator::SshDocker { .. } => format!("/var/lib/hel/workers/{session_id}"),
2269 TargetLocator::AwsEc2 { .. } => format!(".local/share/hel/workers/{session_id}"),
2270 TargetLocator::SshBare { worker_id, .. } => format!(
2271 ".local/share/hel/workers/{}",
2272 worker_id.as_deref().unwrap_or(session_id)
2273 ),
2274 })
2275}
2276mod convert;
2277pub use convert::{
2278 RecordedTarget, StoredTarget, TargetConversionError, locator_needs_connection,
2279 ssh_args_with_identity,
2280};
2281
2282mod ssh;
2283pub use ssh::*;
2284
2285pub fn container_exec(
2286 engine: &str,
2287 container_id: &str,
2288 args: impl IntoIterator<Item = impl Into<String>>,
2289) -> CommandSpec {
2290 let mut command_args = vec!["exec".to_owned(), "-i".to_owned(), container_id.to_owned()];
2291 command_args.extend(args.into_iter().map(Into::into));
2292 CommandSpec::new(engine, command_args)
2293}
2294
2295#[cfg(all(test, unix))]
2296mod executor_tests {
2297 use std::fs;
2298
2299 use super::*;
2300
2301 #[cfg(target_os = "linux")]
2302 #[test]
2303 fn successful_commands_stop_descendants_that_closed_their_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("sh", [
2308 "-c".to_owned(),
2309 "cat >/dev/null; head -c 131072 /dev/zero; sleep 60 </dev/null >/dev/null 2>&1 & echo $! > \"$1\"".to_owned(),
2310 "owned-descendant".to_owned(), pid_file.display().to_string(),
2311 ]);
2312 let executor = CancellableProcessExecutor::with_timeout(Duration::from_secs(5));
2313 let output = if streamed {
2314 executor
2315 .execute_with_stdin(&command, &mut std::io::Cursor::new(vec![b'x'; 256 * 1024]))
2316 } else {
2317 executor.execute(&command)
2318 }
2319 .unwrap();
2320 assert_eq!(output.stdout.len(), 131072);
2321 let pid: i32 = fs::read_to_string(pid_file)
2322 .unwrap()
2323 .trim()
2324 .parse()
2325 .unwrap();
2326 let deadline = Instant::now() + Duration::from_secs(2);
2327 loop {
2328 let state = fs::read_to_string(format!("/proc/{pid}/stat")).ok();
2329 if state.as_ref().is_none_or(|state| {
2330 state
2331 .rsplit_once(") ")
2332 .is_some_and(|(_, fields)| fields.starts_with('Z'))
2333 }) {
2334 break;
2335 }
2336 if Instant::now() >= deadline {
2337 unsafe {
2339 libc::kill(pid, libc::SIGKILL);
2340 }
2341 panic!(
2342 "successful command left descendant {pid} running (streamed={streamed})"
2343 );
2344 }
2345 std::thread::sleep(Duration::from_millis(10));
2346 }
2347 }
2348 }
2349
2350 #[cfg(target_os = "linux")]
2354 #[test]
2355 fn commands_complete_at_leader_exit_when_a_descendant_holds_the_pipes() {
2356 for streamed in [false, true] {
2357 let temp = tempfile::tempdir().unwrap();
2358 let pid_file = temp.path().join("descendant");
2359 let command = CommandSpec::new(
2360 "sh",
2361 [
2362 "-c".to_owned(),
2363 "sleep 300 & echo $! > \"$1\"; echo hi; exit 3".to_owned(),
2364 "held-pipes".to_owned(),
2365 pid_file.display().to_string(),
2366 ],
2367 );
2368 let executor = CancellableProcessExecutor::with_timeout(Duration::from_secs(30));
2369 let started = Instant::now();
2370 let output = if streamed {
2371 executor.execute_with_stdin(&command, &mut std::io::Cursor::new(b"input".to_vec()))
2372 } else {
2373 executor.execute(&command)
2374 }
2375 .unwrap();
2376 assert!(
2377 started.elapsed() < Duration::from_secs(5),
2378 "streamed={streamed} took {:?}",
2379 started.elapsed()
2380 );
2381 assert_eq!(output.status, 3);
2382 assert_eq!(output.stdout, b"hi\n");
2383 let pid: i32 = fs::read_to_string(pid_file)
2384 .unwrap()
2385 .trim()
2386 .parse()
2387 .unwrap();
2388 let deadline = Instant::now() + Duration::from_secs(2);
2389 loop {
2390 let state = fs::read_to_string(format!("/proc/{pid}/stat")).ok();
2391 if state.as_ref().is_none_or(|state| {
2392 state
2393 .rsplit_once(") ")
2394 .is_some_and(|(_, fields)| fields.starts_with('Z'))
2395 }) {
2396 break;
2397 }
2398 if Instant::now() >= deadline {
2399 unsafe {
2401 libc::kill(pid, libc::SIGKILL);
2402 }
2403 panic!("completed command left descendant {pid} (streamed={streamed})");
2404 }
2405 std::thread::sleep(Duration::from_millis(10));
2406 }
2407 }
2408 }
2409
2410 #[test]
2411 fn streamed_deadline_survives_leader_exit_and_inherited_pipes() {
2412 let command = CommandSpec::new(
2413 "sh",
2414 [
2415 "-c",
2416 "head -c 131072 /dev/zero; cat >/dev/null; (trap '' TERM; sleep 60) & exit 0",
2417 ],
2418 );
2419 let mut input = std::io::Cursor::new(vec![b'x'; 256 * 1024]);
2420 let started = Instant::now();
2421 let error = CancellableProcessExecutor::with_timeout(Duration::from_millis(300))
2422 .execute_with_stdin(&command, &mut input)
2423 .unwrap_err();
2424 assert!(error.to_string().contains("cancelled"), "{error:#}");
2425 assert!(started.elapsed() < Duration::from_secs(5));
2426 }
2427
2428 #[test]
2429 fn cleanup_timeout_replaces_an_expired_deadline_and_keeps_shared_cancellation() {
2430 let directory = tempfile::tempdir().unwrap();
2431 let marker = directory.path().join("completed");
2432 let command = CommandSpec::new(
2433 "sh",
2434 [
2435 "-c".to_owned(),
2436 "printf ran > \"$1\"".to_owned(),
2437 "cleanup-deadline".to_owned(),
2438 marker.display().to_string(),
2439 ],
2440 );
2441 let cancelled = Arc::new(AtomicBool::new(false));
2442 let executor =
2443 CancellableProcessExecutor::new(cancelled.clone()).with_deadline(Duration::ZERO);
2444
2445 let output = executor
2446 .execute_cleanup_with_timeout(&command, Duration::from_secs(5))
2447 .unwrap();
2448
2449 assert_eq!(output.status, 0);
2450 assert_eq!(fs::read_to_string(marker).unwrap(), "ran");
2451
2452 cancelled.store(true, Ordering::Release);
2453 let error = executor
2454 .execute_cleanup_with_timeout(&command, Duration::from_secs(5))
2455 .unwrap_err();
2456 assert!(
2457 error.to_string().contains("operation cancelled"),
2458 "{error:#}"
2459 );
2460 }
2461
2462 fn flaky_ssh_script(directory: &Path) -> CommandSpec {
2466 let counter = directory.join("attempts");
2467 let script = format!(
2468 "count=$(cat {counter} 2>/dev/null || echo 0)\n\
2469 echo $((count + 1)) > {counter}\n\
2470 if [ \"$count\" -eq 0 ]; then\n\
2471 echo 'kex_exchange_identification: Connection closed by 10.0.0.1 port 22' >&2\n\
2472 exit 255\n\
2473 fi\n\
2474 echo connected\n",
2475 counter = counter.display()
2476 );
2477 CommandSpec::new("sh", ["-c".to_owned(), script])
2478 .ssh_destination("build@10.0.0.1")
2479 .purpose("run the flaky SSH fixture")
2480 }
2481
2482 fn attempts(directory: &Path) -> u32 {
2483 fs::read_to_string(directory.join("attempts"))
2484 .expect("the fixture records its attempts")
2485 .trim()
2486 .parse()
2487 .expect("attempt count is a number")
2488 }
2489
2490 #[derive(Default)]
2495 struct MasterKilledOnce {
2496 running: std::cell::RefCell<BTreeSet<String>>,
2497 openers: std::cell::Cell<usize>,
2498 sessions: std::cell::RefCell<Vec<Vec<String>>>,
2499 }
2500
2501 impl CommandExecutor for MasterKilledOnce {
2502 fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
2503 let reply = |status: i32, stderr: &str| CommandOutput {
2504 status,
2505 stdout: Vec::new(),
2506 stderr: stderr.as_bytes().to_vec(),
2507 };
2508 if command.ssh_session.is_some() {
2509 return with_ssh_admission(command, self, &|| false, |spawned| {
2511 self.sessions.borrow_mut().push(spawned.args.clone());
2512 if self.sessions.borrow().len() == 1 {
2513 self.running.borrow_mut().clear();
2514 return Ok(reply(255, "Connection closed by UNKNOWN port 65535"));
2515 }
2516 Ok(reply(0, ""))
2517 });
2518 }
2519 let socket = command
2520 .args
2521 .iter()
2522 .find_map(|arg| arg.strip_prefix("ControlPath="))
2523 .expect("a master command names its socket")
2524 .to_owned();
2525 if command.args.windows(2).any(|pair| pair == ["-O", "check"]) {
2526 return Ok(reply(
2527 if self.running.borrow().contains(&socket) {
2528 0
2529 } else {
2530 255
2531 },
2532 "",
2533 ));
2534 }
2535 assert!(command.args.contains(&"ControlMaster=yes".to_owned()));
2536 self.openers.set(self.openers.get() + 1);
2537 self.running.borrow_mut().insert(socket);
2538 Ok(reply(0, ""))
2539 }
2540 }
2541
2542 #[test]
2543 fn a_session_whose_master_died_is_retried_on_a_reopened_master() {
2544 let _guard = ssh::SHARING_TEST_LOCK
2545 .lock()
2546 .unwrap_or_else(std::sync::PoisonError::into_inner);
2547 set_ssh_retry_backoff_for_test(Some(Duration::from_millis(5)));
2548 let socket_dir = tempfile::tempdir_in("/tmp").expect("short socket directory");
2549 set_ssh_connection_sharing_for_test(Some(SshSharingForTest::Directory(
2550 socket_dir.path().to_path_buf(),
2551 )));
2552 let ssh = SshTarget {
2553 destination: "master-killed-once-host".to_owned(),
2554 ssh_args: Vec::new(),
2555 };
2556 let executor = MasterKilledOnce::default();
2557 let output = executor.execute(&ssh_command(&ssh, ["true"]));
2558 set_ssh_connection_sharing_for_test(None);
2559 set_ssh_retry_backoff_for_test(None);
2560
2561 assert_eq!(output.expect("the retry succeeds").status, 0);
2562 let sessions = executor.sessions.borrow();
2563 assert_eq!(sessions.len(), 2);
2564 assert_eq!(
2565 executor.openers.get(),
2566 2,
2567 "the retry reopens the master instead of trusting the earlier check"
2568 );
2569 for args in sessions.iter() {
2570 assert_eq!(args[..6][5], "ProxyCommand=false", "{args:?}");
2571 }
2572 }
2573
2574 #[test]
2576 fn a_transport_rejected_ssh_command_is_retried_once_and_then_succeeds() {
2577 set_ssh_retry_backoff_for_test(Some(Duration::from_millis(5)));
2578 let directory = tempfile::tempdir().expect("temp dir");
2579 let command = flaky_ssh_script(directory.path());
2580
2581 let output = ProcessExecutor
2582 .execute(&command)
2583 .expect("the retry must reach the successful attempt");
2584
2585 assert_eq!(output.status, 0);
2586 assert_eq!(String::from_utf8_lossy(&output.stdout).trim(), "connected");
2587 assert_eq!(attempts(directory.path()), 2);
2588 set_ssh_retry_backoff_for_test(None);
2589 }
2590
2591 #[test]
2593 fn the_cancellable_executor_also_retries_a_transport_rejection() {
2594 set_ssh_retry_backoff_for_test(Some(Duration::from_millis(5)));
2595 let directory = tempfile::tempdir().expect("temp dir");
2596 let command = flaky_ssh_script(directory.path());
2597
2598 let output = CancellableProcessExecutor::new(Arc::new(AtomicBool::new(false)))
2599 .execute(&command)
2600 .expect("the retry must reach the successful attempt");
2601
2602 assert_eq!(output.status, 0);
2603 assert_eq!(attempts(directory.path()), 2);
2604 set_ssh_retry_backoff_for_test(None);
2605 }
2606}