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_work(&self) -> Result<()> {
620 Ok(())
621 }
622 fn end_resumable_work(&self, _label: &'static str) -> Result<()> {
624 Ok(())
625 }
626
627 fn execute_with_stdin(
628 &self,
629 _command: &CommandSpec,
630 _input: &mut (dyn Read + Send),
631 ) -> Result<CommandOutput> {
632 bail!("this command executor does not support streamed stdin")
633 }
634}
635
636pub struct ProvisionStageGuard<'a, E: CommandExecutor + ?Sized> {
640 executor: &'a E,
641 stage: ProvisionStage,
642}
643
644impl<'a, E: CommandExecutor + ?Sized> ProvisionStageGuard<'a, E> {
645 pub fn new(executor: &'a E, stage: ProvisionStage) -> Self {
646 executor.stage_started(stage.clone());
647 Self { executor, stage }
648 }
649}
650
651impl<E: CommandExecutor + ?Sized> Drop for ProvisionStageGuard<'_, E> {
652 fn drop(&mut self) {
653 self.executor.stage_finished(self.stage.clone());
654 }
655}
656
657pub struct ProcessExecutor;
658
659fn with_ssh_admission(
675 command: &CommandSpec,
676 executor: &dyn CommandExecutor,
677 is_cancelled: &dyn Fn() -> bool,
678 mut run: impl FnMut(&CommandSpec) -> Result<CommandOutput>,
679) -> Result<CommandOutput> {
680 let Some(destination) = command.ssh_destination.as_deref() else {
681 return run(command);
682 };
683 for attempt in 1..=SSH_RETRY_ATTEMPTS {
684 let session = command.open_ssh_session(executor)?;
685 let output = {
686 let _permit = SshAdmission::acquire_unless(destination, is_cancelled)?;
687 run(session.command())?
688 };
689 let refusal = ssh_refusal(output.status, &String::from_utf8_lossy(&output.stderr));
690 if refusal == Some(SshRefusal::BeforeAuthentication)
693 && let Some(lease) = session.lease()
694 {
695 lease.invalidate();
696 }
697 drop(session);
698 let Some(refusal) = refusal else {
699 return Ok(output);
700 };
701 let stderr = String::from_utf8_lossy(&output.stderr);
702 if attempt == SSH_RETRY_ATTEMPTS {
703 refusal.log_exhausted(destination, &command.purpose, stderr.trim());
704 return Ok(output);
705 }
706 let delay = ssh_retry_delay(attempt);
707 refusal.log_retry(destination, &command.purpose, attempt, delay, stderr.trim());
708 if !sleep_unless_cancelled(delay, is_cancelled) {
709 bail!("operation cancelled while {}", command.purpose);
710 }
711 }
712 unreachable!("the final attempt always returns");
713}
714
715fn sleep_unless_cancelled(delay: Duration, is_cancelled: &dyn Fn() -> bool) -> bool {
718 let deadline = Instant::now() + delay;
719 loop {
720 if is_cancelled() {
721 return false;
722 }
723 let remaining = deadline.saturating_duration_since(Instant::now());
724 if remaining.is_zero() {
725 return true;
726 }
727 std::thread::sleep(remaining.min(Duration::from_millis(50)));
728 }
729}
730
731fn finished_command(
735 command: &CommandSpec,
736 started: Instant,
737 status: i32,
738 stdout: Vec<u8>,
739 stderr: Vec<u8>,
740) -> CommandOutput {
741 tracing::debug!(
742 purpose = command.purpose.as_str(),
743 program = command.program.as_str(),
744 status,
745 elapsed_ms = started.elapsed().as_millis() as u64,
746 "target command finished"
747 );
748 let output = CommandOutput {
749 status,
750 stdout,
751 stderr,
752 };
753 storage::observe_command_output(command, &output);
754 output
755}
756
757impl ProcessExecutor {
758 fn run_once(&self, command: &CommandSpec) -> Result<CommandOutput> {
760 if let Some(input) = &command.sensitive_stdin {
761 let mut input = std::io::Cursor::new(input.0.as_slice());
762 return stream_command_with_stdin(
764 cancellable_command(command),
765 command,
766 &mut input,
767 &|| false,
768 );
769 }
770 let started = Instant::now();
771 let output = configured_command(command)
772 .stdin(Stdio::null())
773 .output()
774 .with_context(|| format!("run {} for {}", command.program, command.purpose))?;
775 let status = output.status.code().unwrap_or(-1);
776 Ok(finished_command(
777 command,
778 started,
779 status,
780 output.stdout,
781 output.stderr,
782 ))
783 }
784}
785
786impl CommandExecutor for ProcessExecutor {
791 fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
792 crate::runtime::off_async_worker(|| {
793 let _running = BlockingOperation::command(command);
794 with_ssh_admission(command, self, &|| false, |command| self.run_once(command))
795 })
796 }
797
798 fn execute_with_stdin(
799 &self,
800 command: &CommandSpec,
801 input: &mut (dyn Read + Send),
802 ) -> Result<CommandOutput> {
803 crate::runtime::off_async_worker(|| {
804 let _running = BlockingOperation::command(command);
805 let session = command.open_ssh_session(self)?;
808 let _permit = command
809 .ssh_destination
810 .as_deref()
811 .map(SshAdmission::acquire);
812 let command = session.command();
813 let process = cancellable_command(command);
814 stream_command_with_stdin(process, command, input, &|| false)
817 })
818 }
819}
820
821fn stream_command_with_stdin(
830 mut process: Command,
831 command: &CommandSpec,
832 input: &mut (dyn Read + Send),
833 is_cancelled: &(dyn Fn() -> bool + Sync),
834) -> Result<CommandOutput> {
835 let started = Instant::now();
836 if is_cancelled() {
837 bail!("operation cancelled");
838 }
839 let mut child = process
840 .stdin(Stdio::piped())
841 .stdout(Stdio::piped())
842 .stderr(Stdio::piped())
843 .spawn()
844 .with_context(|| format!("run {} for {}", command.program, command.purpose))?;
845 let group =
846 (!command.detaches).then(|| crate::subprocess::ProcessGroupGuard::new(Some(child.id())));
847 let stdin = child
848 .stdin
849 .take()
850 .context("streamed command stdin missing")?;
851 let stdout = child
852 .stdout
853 .take()
854 .context("streamed command stdout missing")?;
855 let stderr = child
856 .stderr
857 .take()
858 .context("streamed command stderr missing")?;
859 let stdout_reader = PipeCollector::spawn(stdout);
862 let stderr_reader = PipeCollector::spawn(stderr);
863 let process_result = std::thread::scope(|scope| -> Result<_> {
864 let input_writer = scope.spawn(move || -> Result<()> {
868 let mut stdin = stdin;
873 let mut buffer = [0_u8; 64 * 1024];
874 loop {
875 if is_cancelled() {
879 bail!("operation cancelled");
880 }
881 let count = input.read(&mut buffer).context("read command input")?;
882 if count == 0 {
883 break;
884 }
885 stdin
886 .write_all(&buffer[..count])
887 .context("stream command input")?;
888 }
889 stdin.flush().context("flush command input")
890 });
891 let mut status = None;
892 let mut exited_at = None;
893 let mut group_killed = false;
894 let status = loop {
895 if is_cancelled() {
896 terminate_cancellable_child(&mut child, false);
897 if let Err(error) = input_writer.join() {
898 tracing::warn!(
899 purpose = command.purpose.as_str(),
900 "streamed command input writer panicked while cancelling: {error:?}"
901 );
902 }
903 bail!("operation cancelled while {}", command.purpose);
904 }
905 match if status.is_some() {
906 Ok(status)
907 } else {
908 child.try_wait()
909 } {
910 Ok(observed) => status = observed,
911 Err(error) => {
912 terminate_cancellable_child(&mut child, false);
913 if let Err(join_error) = input_writer.join() {
914 tracing::warn!(
915 purpose = command.purpose.as_str(),
916 "streamed command input writer panicked while waiting: {join_error:?}"
917 );
918 }
919 return Err(error).with_context(|| format!("wait for {}", command.purpose));
920 }
921 }
922 if let Some(status) = status {
927 let exited_at = *exited_at.get_or_insert_with(Instant::now);
928 let drained = (stdout_reader.is_finished() && stderr_reader.is_finished())
929 || exited_at.elapsed() >= IO_DRAIN_TIMEOUT;
930 if drained && !input_writer.is_finished() && !group_killed {
931 group_killed = true;
932 if let Some(group) = &group {
933 group.kill();
934 }
935 }
936 if drained && input_writer.is_finished() {
937 break status;
938 }
939 }
940 std::thread::sleep(Duration::from_millis(25));
941 };
942 let input_result = input_writer
943 .join()
944 .map_err(|_| anyhow::anyhow!("streamed command input writer panicked"))?;
945 Ok((status, input_result))
946 });
947 let deadline = Instant::now();
948 let stdout = stdout_reader.finish("stdout", deadline)?;
949 let stderr = stderr_reader.finish("stderr", deadline)?;
950 let (status, input_result) = process_result?;
951 drop(group);
952 if status.success() {
953 input_result?;
957 }
958 let status = status.code().unwrap_or(-1);
959 Ok(finished_command(command, started, status, stdout, stderr))
960}
961
962#[derive(Clone)]
963pub struct CancellableProcessExecutor {
964 cancelled: Arc<AtomicBool>,
965 deadline: Option<Instant>,
966}
967
968pub struct ProcessCancellationGuard(Arc<AtomicBool>);
970
971impl Drop for ProcessCancellationGuard {
972 fn drop(&mut self) {
973 self.0.store(true, Ordering::Release);
974 }
975}
976
977impl CancellableProcessExecutor {
978 pub fn cancel_on_drop(&self) -> ProcessCancellationGuard {
979 ProcessCancellationGuard(self.cancelled.clone())
980 }
981
982 pub fn new(cancelled: Arc<AtomicBool>) -> Self {
983 Self {
984 cancelled,
985 deadline: None,
986 }
987 }
988
989 pub fn is_cancelled(&self) -> bool {
990 self.cancelled.load(Ordering::Acquire)
991 || self
992 .deadline
993 .is_some_and(|deadline| Instant::now() >= deadline)
994 }
995
996 pub fn with_timeout(timeout: Duration) -> Self {
997 Self {
998 cancelled: Arc::new(AtomicBool::new(false)),
999 deadline: Some(Instant::now() + timeout),
1000 }
1001 }
1002
1003 pub fn with_deadline(mut self, timeout: Duration) -> Self {
1006 self.deadline = Some(Instant::now() + timeout);
1007 self
1008 }
1009
1010 fn check_cancelled(&self) -> Result<()> {
1011 if self.is_cancelled() {
1012 bail!("operation cancelled");
1013 }
1014 Ok(())
1015 }
1016}
1017
1018fn configured_command(command: &CommandSpec) -> Command {
1019 let mut process = Command::new(&command.program);
1020 if command.clear_env {
1021 process.env_clear();
1022 }
1023 if let Some(cwd) = &command.cwd {
1024 process.current_dir(cwd);
1025 }
1026 process.args(&command.args).envs(&command.env);
1027 process
1028}
1029
1030fn cancellable_command(command: &CommandSpec) -> Command {
1031 #[cfg(unix)]
1032 let mut process = configured_command(command);
1033 #[cfg(not(unix))]
1034 let process = configured_command(command);
1035 #[cfg(unix)]
1036 {
1037 use std::os::unix::process::CommandExt as _;
1038 process.process_group(0);
1039 }
1040 process
1041}
1042
1043const IO_DRAIN_TIMEOUT: Duration = Duration::from_secs(2);
1049
1050trait PollablePipe: Read + Send + 'static {
1053 fn readable(&self, timeout: Duration) -> bool;
1054}
1055
1056macro_rules! pollable_pipe {
1057 ($pipe:ty) => {
1058 impl PollablePipe for $pipe {
1059 #[cfg(unix)]
1060 fn readable(&self, timeout: Duration) -> bool {
1061 use std::os::fd::AsRawFd as _;
1062 let mut poll = libc::pollfd {
1063 fd: self.as_raw_fd(),
1064 events: libc::POLLIN,
1065 revents: 0,
1066 };
1067 let millis = i32::try_from(timeout.as_millis()).unwrap_or(i32::MAX);
1068 unsafe { libc::poll(&raw mut poll, 1, millis) > 0 }
1070 }
1071
1072 #[cfg(not(unix))]
1074 fn readable(&self, _timeout: Duration) -> bool {
1075 true
1076 }
1077 }
1078 };
1079}
1080
1081pollable_pipe!(std::process::ChildStdout);
1082pollable_pipe!(std::process::ChildStderr);
1083
1084struct PipeCollector {
1088 bytes: Arc<std::sync::Mutex<Vec<u8>>>,
1089 stop: Arc<AtomicBool>,
1090 thread: Option<std::thread::JoinHandle<std::io::Result<()>>>,
1091}
1092
1093impl PipeCollector {
1094 fn spawn(mut pipe: impl PollablePipe) -> Self {
1095 let bytes = Arc::new(std::sync::Mutex::new(Vec::new()));
1096 let stop = Arc::new(AtomicBool::new(false));
1097 let (collected, stopped) = (bytes.clone(), stop.clone());
1098 let thread = std::thread::spawn(move || {
1099 let mut chunk = [0_u8; 8192];
1100 while !stopped.load(Ordering::Acquire) {
1101 if !pipe.readable(Duration::from_millis(25)) {
1102 continue;
1103 }
1104 match pipe.read(&mut chunk) {
1105 Ok(0) => break,
1106 Ok(count) => collected
1107 .lock()
1108 .unwrap_or_else(std::sync::PoisonError::into_inner)
1109 .extend_from_slice(&chunk[..count]),
1110 Err(error) if error.kind() == std::io::ErrorKind::Interrupted => {}
1111 Err(error) => return Err(error),
1112 }
1113 }
1114 Ok(())
1115 });
1116 Self {
1117 bytes,
1118 stop,
1119 thread: Some(thread),
1120 }
1121 }
1122
1123 fn is_finished(&self) -> bool {
1124 self.thread
1125 .as_ref()
1126 .is_none_or(std::thread::JoinHandle::is_finished)
1127 }
1128
1129 fn finish(mut self, stream: &str, deadline: Instant) -> Result<Vec<u8>> {
1132 while !self.is_finished() && Instant::now() < deadline {
1133 std::thread::sleep(Duration::from_millis(5));
1134 }
1135 self.stop.store(true, Ordering::Release);
1136 self.thread
1137 .take()
1138 .context("command reader already joined")?
1139 .join()
1140 .map_err(|_| anyhow::anyhow!("command {stream} reader panicked"))?
1141 .with_context(|| format!("read command {stream}"))?;
1142 let mut bytes = self
1143 .bytes
1144 .lock()
1145 .unwrap_or_else(std::sync::PoisonError::into_inner);
1146 Ok(std::mem::take(&mut bytes))
1147 }
1148}
1149
1150impl Drop for PipeCollector {
1151 fn drop(&mut self) {
1152 self.stop.store(true, Ordering::Release);
1153 }
1154}
1155
1156fn terminate_cancellable_child(child: &mut std::process::Child, preserve_children: bool) {
1157 #[cfg(unix)]
1158 if preserve_children {
1159 if let Err(error) = child.kill()
1160 && error.kind() != std::io::ErrorKind::NotFound
1161 {
1162 tracing::warn!(pid = child.id(), %error, "could not terminate cancelled command");
1163 }
1164 } else if let Err(error) =
1165 crate::subprocess::signal_process_group(child.id() as i32, libc::SIGKILL)
1166 {
1167 tracing::warn!(pid = child.id(), %error, "could not terminate cancelled command process group");
1169 }
1170 #[cfg(not(unix))]
1171 {
1172 let _ = preserve_children;
1173 if let Err(error) = child.kill() {
1174 tracing::warn!(pid = child.id(), %error, "could not terminate cancelled command");
1175 }
1176 }
1177 if let Err(error) = child.wait() {
1178 tracing::warn!(pid = child.id(), %error, "could not reap cancelled command");
1179 }
1180}
1181
1182impl CancellableProcessExecutor {
1183 fn run_once(&self, command: &CommandSpec) -> Result<CommandOutput> {
1185 if let Some(input) = &command.sensitive_stdin {
1186 let mut input = std::io::Cursor::new(input.0.as_slice());
1187 return stream_command_with_stdin(
1189 cancellable_command(command),
1190 command,
1191 &mut input,
1192 &|| self.is_cancelled(),
1193 );
1194 }
1195 let started = Instant::now();
1196 self.check_cancelled()?;
1197 let mut child = cancellable_command(command)
1198 .stdin(Stdio::null())
1199 .stdout(Stdio::piped())
1200 .stderr(Stdio::piped())
1201 .spawn()
1202 .with_context(|| format!("run {} for {}", command.program, command.purpose))?;
1203 let group = (!command.detaches && !command.preserve_children_on_cancel)
1204 .then(|| crate::subprocess::ProcessGroupGuard::new(Some(child.id())));
1205 let stdout = child.stdout.take().context("command stdout missing")?;
1206 let stderr = child.stderr.take().context("command stderr missing")?;
1207 let stdout_reader = PipeCollector::spawn(stdout);
1208 let stderr_reader = PipeCollector::spawn(stderr);
1209 let mut status = None;
1210 let mut exited_at = None;
1211 let status = loop {
1212 if self.is_cancelled() {
1213 terminate_cancellable_child(&mut child, command.preserve_children_on_cancel);
1214 let deadline = Instant::now() + IO_DRAIN_TIMEOUT;
1215 for (stream, reader) in [("stdout", stdout_reader), ("stderr", stderr_reader)] {
1216 if let Err(error) = reader.finish(stream, deadline) {
1217 tracing::warn!(stream, %error, "cancelled command reader failed");
1218 }
1219 }
1220 bail!("operation cancelled while {}", command.purpose);
1221 }
1222 if status.is_none() {
1223 status = child
1224 .try_wait()
1225 .with_context(|| format!("wait for {}", command.purpose))?;
1226 }
1227 if let Some(status) = status {
1230 let exited_at = *exited_at.get_or_insert_with(Instant::now);
1231 if (stdout_reader.is_finished() && stderr_reader.is_finished())
1232 || exited_at.elapsed() >= IO_DRAIN_TIMEOUT
1233 {
1234 break status;
1235 }
1236 }
1237 std::thread::sleep(Duration::from_millis(25));
1238 };
1239 let deadline = Instant::now();
1240 let stdout = stdout_reader.finish("stdout", deadline)?;
1241 let stderr = stderr_reader.finish("stderr", deadline)?;
1242 let status = status.code().unwrap_or(-1);
1243 drop(group);
1244 Ok(finished_command(command, started, status, stdout, stderr))
1245 }
1246}
1247
1248impl CommandExecutor for CancellableProcessExecutor {
1249 fn execute_cleanup(&self, command: &CommandSpec) -> Result<CommandOutput> {
1250 self.clone()
1251 .with_deadline(Duration::from_secs(15))
1252 .execute(command)
1253 }
1254
1255 fn execute_cleanup_with_timeout(
1256 &self,
1257 command: &CommandSpec,
1258 timeout: Duration,
1259 ) -> Result<CommandOutput> {
1260 self.clone().with_deadline(timeout).execute(command)
1261 }
1262
1263 fn cancellation_requested(&self) -> bool {
1264 self.is_cancelled()
1265 }
1266
1267 fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
1268 crate::runtime::off_async_worker(|| {
1269 let _running = BlockingOperation::command(command);
1270 with_ssh_admission(command, self, &|| self.is_cancelled(), |command| {
1271 self.run_once(command)
1272 })
1273 })
1274 }
1275
1276 fn execute_with_stdin(
1277 &self,
1278 command: &CommandSpec,
1279 input: &mut (dyn Read + Send),
1280 ) -> Result<CommandOutput> {
1281 crate::runtime::off_async_worker(|| {
1282 let _running = BlockingOperation::command(command);
1283 let session = command.open_ssh_session(self)?;
1286 let _permit = command
1287 .ssh_destination
1288 .as_deref()
1289 .map(|destination| {
1290 SshAdmission::acquire_unless(destination, &|| self.is_cancelled())
1291 })
1292 .transpose()?;
1293 let command = session.command();
1294 stream_command_with_stdin(cancellable_command(command), command, input, &|| {
1298 self.is_cancelled()
1299 })
1300 })
1301 }
1302}
1303
1304#[derive(Debug, Clone, PartialEq, Eq)]
1308pub struct CommandTimedOut {
1309 pub program: String,
1310 pub purpose: String,
1311 pub timeout: Duration,
1312}
1313
1314impl std::fmt::Display for CommandTimedOut {
1315 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1316 write!(
1317 formatter,
1318 "`{}` did not answer within {} seconds while trying to {}",
1319 self.program,
1320 self.timeout.as_secs(),
1321 self.purpose
1322 )
1323 }
1324}
1325
1326impl std::error::Error for CommandTimedOut {}
1327
1328#[derive(Debug, Clone, Copy)]
1337pub struct BoundedProcessExecutor {
1338 timeout: Duration,
1339}
1340
1341impl BoundedProcessExecutor {
1342 pub const fn new(timeout: Duration) -> Self {
1343 Self { timeout }
1344 }
1345}
1346
1347impl CommandExecutor for BoundedProcessExecutor {
1348 fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
1349 let executor = CancellableProcessExecutor::with_timeout(self.timeout);
1350 executor.execute(command).map_err(|error| {
1351 if executor.is_cancelled() {
1352 anyhow::Error::new(CommandTimedOut {
1353 program: command.program.clone(),
1354 purpose: command.purpose.clone(),
1355 timeout: self.timeout,
1356 })
1357 } else {
1358 error
1359 }
1360 })
1361 }
1362
1363 fn execute_cleanup_with_timeout(
1364 &self,
1365 command: &CommandSpec,
1366 timeout: Duration,
1367 ) -> Result<CommandOutput> {
1368 CancellableProcessExecutor::with_timeout(self.timeout.min(timeout)).execute(command)
1371 }
1372
1373 fn execute_with_stdin(
1374 &self,
1375 command: &CommandSpec,
1376 input: &mut (dyn Read + Send),
1377 ) -> Result<CommandOutput> {
1378 CancellableProcessExecutor::with_timeout(self.timeout).execute_with_stdin(command, input)
1379 }
1380}
1381
1382#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1383pub struct CommandPlan {
1384 pub description: String,
1385 pub commands: Vec<CommandSpec>,
1386}
1387
1388impl CommandPlan {
1389 pub fn provide_target_environment_secret(
1393 &mut self,
1394 target: &TargetTemplate,
1395 name: &str,
1396 value: &str,
1397 ) -> Result<()> {
1398 ensure!(
1399 !name.is_empty()
1400 && name.bytes().enumerate().all(|(index, byte)| byte == b'_'
1401 || byte.is_ascii_alphabetic()
1402 || (index > 0 && byte.is_ascii_digit())),
1403 "invalid secret environment variable name"
1404 );
1405 ensure!(
1406 !value.as_bytes().contains(&b'\n') && !value.as_bytes().contains(&b'\r'),
1407 "secret environment value cannot contain a newline"
1408 );
1409 let command = self
1410 .commands
1411 .iter_mut()
1412 .find(|command| command.creates_target)
1413 .context("provisioning plan has no target creation command")?;
1414 let read_and_export = format!("IFS= read -r {name} || exit 1; export {name};");
1415 match target {
1416 TargetTemplate::LocalPodman(_)
1417 | TargetTemplate::LocalDocker(_)
1418 | TargetTemplate::AppleContainer(_) => {
1419 let program = std::mem::replace(&mut command.program, "sh".to_owned());
1420 let args = std::mem::take(&mut command.args);
1421 command.args = vec![
1422 "-c".to_owned(),
1423 format!("{read_and_export} exec \"$@\""),
1424 "mj-secret-env".to_owned(),
1425 program,
1426 ];
1427 command.args.extend(args);
1428 }
1429 TargetTemplate::SshPodman { .. } | TargetTemplate::SshDocker { .. } => {
1430 let remote = command
1431 .args
1432 .last_mut()
1433 .context("remote container command has no SSH command argument")?;
1434 *remote = format!("{read_and_export} exec {remote}");
1435 }
1436 TargetTemplate::LocalBare
1437 | TargetTemplate::AwsEc2(_)
1438 | TargetTemplate::SshBare { .. } => {
1439 bail!("target does not support inherited container environment")
1440 }
1441 }
1442 let mut input = value.as_bytes().to_vec();
1443 input.push(b'\n');
1444 command.sensitive_stdin = Some(SensitiveCommandInput(input));
1445 Ok(())
1446 }
1447
1448 pub fn execute(&self, executor: &impl CommandExecutor) -> Result<Vec<CommandOutput>> {
1449 let mut outputs = Vec::with_capacity(self.commands.len());
1450 for command in &self.commands {
1451 let output = executor.execute(command)?;
1452 if output.status != 0 {
1453 bail!(
1454 "{} failed with status {}: {}",
1455 command.purpose,
1456 output.status,
1457 String::from_utf8_lossy(&output.stderr)
1458 );
1459 }
1460 outputs.push(output);
1461 }
1462 Ok(outputs)
1463 }
1464
1465 pub fn execute_concurrent(
1477 &self,
1478 executor: &(impl CommandExecutor + Sync),
1479 ) -> Result<Vec<CommandOutput>> {
1480 let mut outputs = Vec::with_capacity(self.commands.len());
1481 let mut index = 0;
1482 while index < self.commands.len() {
1483 let group = self.commands[index].parallel_group;
1484 let mut end = index + 1;
1485 if group.is_some() {
1486 while end < self.commands.len() && self.commands[end].parallel_group == group {
1487 end += 1;
1488 }
1489 }
1490 let batch = &self.commands[index..end];
1491 if let [command] = batch {
1492 outputs.push(checked_command_output(command, executor.execute(command)?)?);
1493 } else {
1494 let results: Vec<Result<CommandOutput>> = std::thread::scope(|scope| {
1495 let handles: Vec<_> = batch
1496 .iter()
1497 .map(|command| scope.spawn(|| executor.execute(command)))
1498 .collect();
1499 handles
1500 .into_iter()
1501 .map(|handle| match handle.join() {
1502 Ok(result) => result,
1503 Err(panic) => Err(anyhow::anyhow!(
1504 "concurrent command thread panicked: {}",
1505 command_thread_panic_message(panic.as_ref())
1506 )),
1507 })
1508 .collect()
1509 });
1510 for (command, result) in batch.iter().zip(results) {
1511 outputs.push(checked_command_output(command, result?)?);
1512 }
1513 }
1514 index = end;
1515 }
1516 Ok(outputs)
1517 }
1518
1519 pub fn split_at_target_creation(&self) -> Option<(Self, Self)> {
1527 let created = self
1528 .commands
1529 .iter()
1530 .position(|command| command.creates_target)?;
1531 let (creation, remainder) = self.commands.split_at(created + 1);
1532 Some((
1533 Self {
1534 description: self.description.clone(),
1535 commands: creation.to_vec(),
1536 },
1537 Self {
1538 description: self.description.clone(),
1539 commands: remainder.to_vec(),
1540 },
1541 ))
1542 }
1543}
1544
1545pub fn checked_command_output(
1549 command: &CommandSpec,
1550 output: CommandOutput,
1551) -> Result<CommandOutput> {
1552 if output.status != 0 {
1553 bail!(
1554 "{} failed with status {}: {}",
1555 command.purpose,
1556 output.status,
1557 String::from_utf8_lossy(&output.stderr)
1558 );
1559 }
1560 Ok(output)
1561}
1562
1563pub fn command_thread_panic_message(payload: &(dyn std::any::Any + Send)) -> String {
1565 if let Some(message) = payload.downcast_ref::<&str>() {
1566 (*message).to_owned()
1567 } else if let Some(message) = payload.downcast_ref::<String>() {
1568 message.clone()
1569 } else {
1570 "non-string panic payload".to_owned()
1571 }
1572}
1573
1574#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1575pub struct RepositorySpec {
1576 pub url: Option<String>,
1578 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1579 pub push_urls: Vec<String>,
1580 pub destination: String,
1581 pub git_ref: Option<String>,
1582 #[serde(default, skip_serializing_if = "Option::is_none")]
1585 pub reference: Option<String>,
1586}
1587
1588#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1589pub struct ProjectBundleSpec {
1590 pub primary: String,
1591 pub repositories: Vec<RepositorySpec>,
1592}
1593
1594impl ProjectBundleSpec {
1595 pub fn validate(&self) -> Result<()> {
1596 validate_relative_path(&self.primary)?;
1597 if self.repositories.is_empty() {
1598 bail!("a project bundle must contain at least one repository");
1599 }
1600 let mut destinations = std::collections::BTreeSet::new();
1601 for repository in &self.repositories {
1602 validate_relative_path(&repository.destination)?;
1603 ensure!(
1604 repository
1605 .url
1606 .as_deref()
1607 .is_some_and(|url| !url.trim().is_empty() && !url.starts_with('-')),
1608 "isolated repositories require a network Git remote; configure a remote or use a raw local session"
1609 );
1610 crate::remote_git::validate_network_url(
1611 repository.url.as_deref().expect("checked above"),
1612 )?;
1613 for push_url in &repository.push_urls {
1614 crate::remote_git::validate_network_url(push_url)?;
1615 }
1616 ensure!(
1617 repository.git_ref.is_none(),
1618 "git_ref is no longer supported; remove it to start from the remote's default branch"
1619 );
1620 if !destinations.insert(&repository.destination) {
1621 bail!(
1622 "duplicate repository destination {}",
1623 repository.destination
1624 );
1625 }
1626 }
1627 if !destinations.contains(&self.primary) {
1628 bail!("primary repository is not present in the bundle");
1629 }
1630 Ok(())
1631 }
1632}
1633
1634#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1635#[serde(tag = "kind", rename_all = "snake_case")]
1636pub enum PodmanWorkspaceStorage {
1637 PodmanVolume,
1638 HostHelper {
1639 root: String,
1640 helper: Vec<String>,
1641 },
1642 #[default]
1643 ContainerLayer,
1644}
1645
1646#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1647pub struct ContainerTemplate {
1648 pub image: String,
1649 #[serde(default)]
1650 pub pull_policy: ImagePullPolicy,
1651 #[serde(default)]
1652 pub extra_run_args: Vec<String>,
1653 #[serde(default)]
1654 pub workspace_storage: PodmanWorkspaceStorage,
1655 #[serde(default)]
1658 pub build_cache: Option<crate::config::TargetBuildCache>,
1659}
1660
1661impl ImagePullPolicy {
1662 pub fn resolve(self, image: &str) -> Self {
1665 if self != Self::Auto {
1666 return self;
1667 }
1668 if image_is_digest_pinned(image) {
1669 Self::Missing
1670 } else if image_is_remote(image) && image_uses_latest_tag(image) {
1671 Self::Newer
1672 } else {
1673 Self::Missing
1674 }
1675 }
1676
1677 pub fn at_launch(self, image: &str) -> Self {
1682 if self == Self::Auto {
1683 Self::Missing
1684 } else {
1685 self.resolve(image)
1686 }
1687 }
1688
1689 pub fn describe(self, image: &str) -> &'static str {
1694 match self {
1695 Self::Always => "Pull every launch",
1696 Self::Newer => "Pull when the registry is newer",
1697 Self::Missing => "Pull only if missing",
1698 Self::Never => "Never pull",
1699 Self::Auto => match self.resolve(image) {
1700 Self::Newer => "Pull if missing at launch; refresh :latest in background",
1701 _ => "Pull if missing",
1702 },
1703 }
1704 }
1705
1706 pub fn podman_value(self) -> &'static str {
1708 match self {
1709 Self::Always => "always",
1710 Self::Newer => "newer",
1711 Self::Missing => "missing",
1712 Self::Never => "never",
1713 Self::Auto => unreachable!("auto pull policy must resolve"),
1714 }
1715 }
1716}
1717
1718#[derive(Debug, Clone, PartialEq, Eq)]
1724pub enum ImageHost {
1725 LocalPodman,
1726 LocalDocker,
1727 AppleContainer,
1728 SshPodman(SshTarget),
1729 SshDocker(SshTarget),
1730}
1731
1732impl ImageHost {
1733 pub const fn engine(&self) -> &'static str {
1734 match self {
1735 Self::LocalPodman | Self::SshPodman(_) => "podman",
1736 Self::LocalDocker | Self::SshDocker(_) => "docker",
1737 Self::AppleContainer => "container",
1738 }
1739 }
1740
1741 pub fn label(&self) -> String {
1743 match self {
1744 Self::LocalPodman => "local podman".to_owned(),
1745 Self::LocalDocker => "local docker".to_owned(),
1746 Self::AppleContainer => "apple container".to_owned(),
1747 Self::SshPodman(ssh) => format!("podman on {}", ssh.destination),
1748 Self::SshDocker(ssh) => format!("docker on {}", ssh.destination),
1749 }
1750 }
1751
1752 fn command(&self, args: Vec<String>, purpose: String) -> CommandSpec {
1753 match self {
1754 Self::LocalPodman | Self::LocalDocker | Self::AppleContainer => {
1755 CommandSpec::new(args[0].clone(), args[1..].iter().cloned())
1756 }
1757 Self::SshPodman(ssh) | Self::SshDocker(ssh) => ssh_command_owned(ssh, args),
1758 }
1759 .purpose(purpose)
1760 }
1761}
1762
1763#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
1770pub enum RefreshWhen {
1771 WhenAbsent,
1773 Always,
1775}
1776
1777#[derive(Debug, Clone, PartialEq, Eq)]
1780pub struct ImageRefresh {
1781 pub host: ImageHost,
1782 pub image: String,
1783 pub platform: Option<String>,
1784 pub when: RefreshWhen,
1787 pub image_id: CommandSpec,
1790 pub pull: CommandSpec,
1791 pub prune: Option<CommandSpec>,
1794}
1795
1796pub fn image_refresh(
1803 host: ImageHost,
1804 image: &str,
1805 platform: Option<&str>,
1806 pull_policy: ImagePullPolicy,
1807) -> Option<ImageRefresh> {
1808 let when = match pull_policy.resolve(image) {
1809 ImagePullPolicy::Always | ImagePullPolicy::Newer => RefreshWhen::Always,
1810 ImagePullPolicy::Missing => RefreshWhen::WhenAbsent,
1811 ImagePullPolicy::Never => return None,
1812 ImagePullPolicy::Auto => unreachable!("auto pull policy must resolve"),
1813 };
1814 let engine = host.engine();
1815 let apple = matches!(host, ImageHost::AppleContainer);
1819 let mut image_id_args = vec![engine.to_owned(), "image".to_owned(), "inspect".to_owned()];
1820 if !apple {
1821 image_id_args.push("--format".to_owned());
1822 image_id_args.push("{{.Id}}".to_owned());
1823 }
1824 image_id_args.push(image.to_owned());
1825 let image_id = host.command(
1826 image_id_args,
1827 format!("read the cached id of container image {image}"),
1828 );
1829 let mut pull_args = vec![engine.to_owned()];
1830 if apple {
1831 pull_args.push("image".to_owned());
1832 }
1833 pull_args.push("pull".to_owned());
1834 if let Some(platform) = platform.filter(|_| !apple) {
1836 pull_args.push(format!("--platform={platform}"));
1837 }
1838 pull_args.push(image.to_owned());
1839 let pull = host.command(pull_args, format!("refresh container image {image}"));
1840 let prune = (!apple).then(|| {
1841 host.command(
1842 vec![
1843 engine.to_owned(),
1844 "image".to_owned(),
1845 "prune".to_owned(),
1846 "-f".to_owned(),
1847 ],
1848 "remove dangling container images".to_owned(),
1849 )
1850 });
1851 Some(ImageRefresh {
1852 host,
1853 image: image.to_owned(),
1854 platform: platform.map(str::to_owned),
1855 when,
1856 image_id,
1857 pull,
1858 prune,
1859 })
1860}
1861
1862fn image_is_digest_pinned(image: &str) -> bool {
1863 image
1864 .rsplit_once('@')
1865 .is_some_and(|(_, digest)| !digest.is_empty())
1866}
1867
1868fn image_is_remote(image: &str) -> bool {
1869 !image.starts_with("localhost/") && !image.starts_with("local/")
1870}
1871
1872fn image_uses_latest_tag(image: &str) -> bool {
1873 let name = image.split_once('@').map_or(image, |(name, _)| name);
1874 let final_component = name.rsplit('/').next().unwrap_or(name);
1875 !final_component.contains(':') || final_component.ends_with(":latest")
1876}
1877
1878#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1879pub struct SshTarget {
1880 pub destination: String,
1881 #[serde(default)]
1882 pub ssh_args: Vec<String>,
1883}
1884
1885#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1886pub struct AwsTemplate {
1887 pub profile: String,
1888 pub region: String,
1889 pub launch_template: String,
1890 pub launch_template_version: Option<String>,
1891 pub instance_type: Option<String>,
1892 pub ssh: SshTarget,
1893}
1894
1895#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1896#[serde(tag = "kind", rename_all = "snake_case")]
1897pub enum TargetTemplate {
1898 LocalBare,
1899 LocalPodman(ContainerTemplate),
1900 LocalDocker(ContainerTemplate),
1901 AppleContainer(ContainerTemplate),
1902 AwsEc2(AwsTemplate),
1903 SshBare {
1904 ssh: SshTarget,
1905 #[serde(default = "default_ssh_prefix")]
1906 workspace_prefix: String,
1907 },
1908 SshPodman {
1909 ssh: SshTarget,
1910 container: ContainerTemplate,
1911 },
1912 SshDocker {
1913 ssh: SshTarget,
1914 container: ContainerTemplate,
1915 },
1916}
1917
1918fn default_ssh_prefix() -> String {
1919 ".local/share/hel/workspaces".to_owned()
1920}
1921
1922#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1923#[serde(tag = "kind", rename_all = "snake_case")]
1924pub enum PodmanWorkspaceLocator {
1925 #[default]
1926 ContainerLayer,
1927 Volume {
1928 name: String,
1929 },
1930 HostPath {
1931 path: String,
1932 helper: Vec<String>,
1933 resource: String,
1934 },
1935}
1936
1937#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1938#[serde(tag = "kind", rename_all = "snake_case")]
1939pub enum TargetLocator {
1940 LocalBare {
1941 worker_root: String,
1942 },
1943 LocalPodman {
1944 container_id: String,
1945 #[serde(default)]
1946 workspace_storage: PodmanWorkspaceLocator,
1947 #[serde(default, skip_serializing_if = "Option::is_none")]
1951 borrowed_from: Option<String>,
1952 },
1953 LocalDocker {
1954 container_id: String,
1955 #[serde(default, skip_serializing_if = "Option::is_none")]
1959 borrowed_from: Option<String>,
1960 },
1961 AppleContainer {
1962 container_id: String,
1963 #[serde(default, skip_serializing_if = "Option::is_none")]
1967 borrowed_from: Option<String>,
1968 },
1969 AwsEc2 {
1970 profile: String,
1971 region: String,
1972 instance_id: String,
1973 ssh: SshTarget,
1974 workspace: String,
1975 },
1976 SshBare {
1977 ssh: SshTarget,
1978 workspace: String,
1979 #[serde(default, skip_serializing_if = "Option::is_none")]
1981 worker_id: Option<String>,
1982 },
1983 SshPodman {
1984 ssh: SshTarget,
1985 container_id: String,
1986 #[serde(default)]
1987 workspace_storage: PodmanWorkspaceLocator,
1988 #[serde(default, skip_serializing_if = "Option::is_none")]
1992 borrowed_from: Option<String>,
1993 },
1994 SshDocker {
1995 ssh: SshTarget,
1996 container_id: String,
1997 #[serde(default, skip_serializing_if = "Option::is_none")]
2001 borrowed_from: Option<String>,
2002 },
2003}
2004
2005impl TargetTemplate {
2006 pub const fn container_engine(&self) -> Option<&'static str> {
2007 match self {
2008 Self::LocalPodman(_) | Self::SshPodman { .. } => Some("podman"),
2009 Self::LocalDocker(_) | Self::SshDocker { .. } => Some("docker"),
2010 Self::AppleContainer(_) => Some("container"),
2011 _ => None,
2012 }
2013 }
2014
2015 pub fn image_host(&self) -> Option<(ImageHost, &ContainerTemplate)> {
2018 match self {
2019 Self::LocalPodman(container) => Some((ImageHost::LocalPodman, container)),
2020 Self::LocalDocker(container) => Some((ImageHost::LocalDocker, container)),
2021 Self::AppleContainer(container) => Some((ImageHost::AppleContainer, container)),
2022 Self::SshPodman { ssh, container } => {
2023 Some((ImageHost::SshPodman(ssh.clone()), container))
2024 }
2025 Self::SshDocker { ssh, container } => {
2026 Some((ImageHost::SshDocker(ssh.clone()), container))
2027 }
2028 Self::LocalBare | Self::AwsEc2(_) | Self::SshBare { .. } => None,
2029 }
2030 }
2031}
2032
2033impl TargetLocator {
2034 pub const fn kind_name(&self) -> &'static str {
2036 match self {
2037 Self::LocalBare { .. } => "local-bare",
2038 Self::LocalPodman { .. } => "local-podman",
2039 Self::LocalDocker { .. } => "local-docker",
2040 Self::AppleContainer { .. } => "apple-container",
2041 Self::AwsEc2 { .. } => "aws-ec2",
2042 Self::SshBare { .. } => "ssh-bare",
2043 Self::SshPodman { .. } => "ssh-podman",
2044 Self::SshDocker { .. } => "ssh-docker",
2045 }
2046 }
2047
2048 pub const fn container_engine(&self) -> Option<&'static str> {
2049 match self {
2050 Self::LocalPodman { .. } | Self::SshPodman { .. } => Some("podman"),
2051 Self::LocalDocker { .. } | Self::SshDocker { .. } => Some("docker"),
2052 Self::AppleContainer { .. } => Some("container"),
2053 _ => None,
2054 }
2055 }
2056}
2057
2058#[derive(Debug, Clone, PartialEq, Eq)]
2062pub struct TargetRecoveryPlan {
2063 pub exists: CommandSpec,
2064 pub inspect: CommandSpec,
2065 pub start: CommandSpec,
2066 pub session_id: String,
2067}
2068
2069#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2070pub enum TargetRecoveryOutcome {
2071 NotRequired,
2072 Missing,
2073 AlreadyRunning,
2074 Started,
2075}
2076
2077pub fn resource_name(session_id: &str) -> Result<String> {
2078 validate_session_id(session_id)?;
2079 let readable: String = session_id
2080 .chars()
2081 .filter(|character| character.is_ascii_alphanumeric())
2082 .take(12)
2083 .map(|character| character.to_ascii_lowercase())
2084 .collect();
2085 let digest = Sha256::digest(session_id.as_bytes());
2086 Ok(format!(
2087 "mj-{readable}-{:02x}{:02x}{:02x}",
2088 digest[0], digest[1], digest[2]
2089 ))
2090}
2091
2092pub fn move_resource_name(session_id: &str, operation_id: &str) -> Result<String> {
2094 let digest = Sha256::digest(operation_id.as_bytes());
2095 Ok(format!(
2096 "{}-move-{}",
2097 resource_name(session_id)?,
2098 crate::hex::lower_hex(&digest[..8])
2099 ))
2100}
2101
2102pub fn resource_name_belongs_to(name: &str, session_id: &str) -> Result<bool> {
2103 let base = resource_name(session_id)?;
2104 Ok(name == base
2105 || name
2106 .strip_prefix(&format!("{base}-move-"))
2107 .is_some_and(|suffix| {
2108 suffix.len() == 16 && suffix.bytes().all(|b| b.is_ascii_hexdigit())
2109 }))
2110}
2111
2112pub fn podman_workspace_locator(
2113 template: &ContainerTemplate,
2114 session_id: &str,
2115) -> Result<PodmanWorkspaceLocator> {
2116 podman_workspace_locator_named(template, &resource_name(session_id)?)
2117}
2118
2119pub fn podman_workspace_locator_named(
2120 template: &ContainerTemplate,
2121 name: &str,
2122) -> Result<PodmanWorkspaceLocator> {
2123 let resource = format!("{name}-workspace");
2124 match &template.workspace_storage {
2125 PodmanWorkspaceStorage::PodmanVolume => {
2126 Ok(PodmanWorkspaceLocator::Volume { name: resource })
2127 }
2128 PodmanWorkspaceStorage::HostHelper { root, helper } => {
2129 let root = Path::new(root);
2130 ensure!(
2131 crate::target_path::is_absolute(root),
2132 "Podman workspace storage root must be absolute"
2133 );
2134 ensure!(
2135 !helper.is_empty() && helper.iter().all(|argument| !argument.is_empty()),
2136 "Podman workspace storage helper must contain non-empty arguments"
2137 );
2138 Ok(PodmanWorkspaceLocator::HostPath {
2139 path: root.join(&resource).to_string_lossy().into_owned(),
2140 helper: helper.clone(),
2141 resource,
2142 })
2143 }
2144 PodmanWorkspaceStorage::ContainerLayer => Ok(PodmanWorkspaceLocator::ContainerLayer),
2145 }
2146}
2147
2148pub fn container_workspace_root(recorded: Option<&Path>) -> String {
2156 recorded.map_or_else(
2157 || CONTAINER_WORKSPACE.to_owned(),
2158 |path| path.to_string_lossy().into_owned(),
2159 )
2160}
2161
2162pub fn new_container_workspace(session_id: &str) -> Result<PathBuf> {
2164 validate_session_id(session_id)?;
2165 Ok(Path::new(CONTAINER_WORKSPACE).join(session_id))
2166}
2167
2168pub fn aws_workspace(session_id: &str) -> String {
2174 format!(".local/share/hel/workspaces/{session_id}")
2175}
2176
2177pub fn workspace_for(template: &TargetTemplate, session_id: &str) -> Result<String> {
2178 validate_session_id(session_id)?;
2179 match template {
2180 TargetTemplate::LocalBare => bail!("local bare projects use their selected directory"),
2181 TargetTemplate::LocalPodman(_)
2184 | TargetTemplate::LocalDocker(_)
2185 | TargetTemplate::AppleContainer(_)
2186 | TargetTemplate::SshPodman { .. }
2187 | TargetTemplate::SshDocker { .. } => {
2188 bail!("container targets use the session's recorded container workspace")
2189 }
2190 TargetTemplate::AwsEc2(_) => Ok(aws_workspace(session_id)),
2191 TargetTemplate::SshBare {
2192 workspace_prefix, ..
2193 } => {
2194 validate_workspace_prefix(workspace_prefix)?;
2195 let prefix = workspace_prefix
2200 .strip_prefix("~/")
2201 .unwrap_or(workspace_prefix);
2202 Ok(format!("{}/{session_id}", prefix.trim_end_matches('/')))
2203 }
2204 }
2205}
2206
2207pub fn command_on_locator(
2209 locator: &TargetLocator,
2210 session_id: &str,
2211 args: Vec<String>,
2212 purpose: impl Into<String>,
2213) -> Result<CommandSpec> {
2214 verify_locator(locator, session_id)?;
2215 if args.is_empty() {
2216 bail!("target command must not be empty");
2217 }
2218 Ok(locator_command(locator, args).purpose(purpose))
2219}
2220
2221pub fn locator_command(locator: &TargetLocator, args: Vec<String>) -> CommandSpec {
2225 match locator {
2226 TargetLocator::LocalBare { .. } => {
2227 let mut args = args.into_iter();
2228 let program = args.next().expect("target command must not be empty");
2229 CommandSpec::new(program, args)
2230 }
2231 TargetLocator::LocalPodman { container_id, .. }
2232 | TargetLocator::LocalDocker { container_id, .. }
2233 | TargetLocator::AppleContainer { container_id, .. } => container_exec(
2234 locator.container_engine().expect("local container"),
2235 container_id,
2236 args,
2237 ),
2238 TargetLocator::AwsEc2 { ssh, .. } | TargetLocator::SshBare { ssh, .. } => {
2239 ssh_command_owned(ssh, args)
2240 }
2241 TargetLocator::SshPodman {
2242 ssh, container_id, ..
2243 }
2244 | TargetLocator::SshDocker {
2245 ssh, container_id, ..
2246 } => {
2247 let mut remote = vec![
2248 locator
2249 .container_engine()
2250 .expect("remote container")
2251 .to_owned(),
2252 "exec".to_owned(),
2253 "-i".to_owned(),
2254 container_id.to_owned(),
2255 ];
2256 remote.extend(args);
2257 ssh_command_owned(ssh, remote)
2258 }
2259 }
2260}
2261pub fn worker_root(locator: &TargetLocator, session_id: &str) -> Result<String> {
2262 verify_locator(locator, session_id)?;
2263 Ok(match locator {
2264 TargetLocator::LocalBare { worker_root } => worker_root.clone(),
2265 TargetLocator::LocalPodman { .. }
2266 | TargetLocator::LocalDocker { .. }
2267 | TargetLocator::AppleContainer { .. }
2268 | TargetLocator::SshPodman { .. }
2269 | TargetLocator::SshDocker { .. } => format!("/var/lib/hel/workers/{session_id}"),
2270 TargetLocator::AwsEc2 { .. } => format!(".local/share/hel/workers/{session_id}"),
2271 TargetLocator::SshBare { worker_id, .. } => format!(
2272 ".local/share/hel/workers/{}",
2273 worker_id.as_deref().unwrap_or(session_id)
2274 ),
2275 })
2276}
2277mod convert;
2278pub use convert::{
2279 RecordedTarget, StoredTarget, TargetConversionError, locator_needs_connection,
2280 ssh_args_with_identity,
2281};
2282
2283mod ssh;
2284pub use ssh::*;
2285
2286pub fn container_exec(
2287 engine: &str,
2288 container_id: &str,
2289 args: impl IntoIterator<Item = impl Into<String>>,
2290) -> CommandSpec {
2291 let mut command_args = vec!["exec".to_owned(), "-i".to_owned(), container_id.to_owned()];
2292 command_args.extend(args.into_iter().map(Into::into));
2293 CommandSpec::new(engine, command_args)
2294}
2295
2296#[cfg(all(test, unix))]
2297mod executor_tests {
2298 use std::fs;
2299
2300 use super::*;
2301
2302 #[cfg(target_os = "linux")]
2303 #[test]
2304 fn successful_commands_stop_descendants_that_closed_their_pipes() {
2305 for streamed in [false, true] {
2306 let temp = tempfile::tempdir().unwrap();
2307 let pid_file = temp.path().join("descendant");
2308 let command = CommandSpec::new("sh", [
2309 "-c".to_owned(),
2310 "cat >/dev/null; head -c 131072 /dev/zero; sleep 60 </dev/null >/dev/null 2>&1 & echo $! > \"$1\"".to_owned(),
2311 "owned-descendant".to_owned(), pid_file.display().to_string(),
2312 ]);
2313 let executor = CancellableProcessExecutor::with_timeout(Duration::from_secs(5));
2314 let output = if streamed {
2315 executor
2316 .execute_with_stdin(&command, &mut std::io::Cursor::new(vec![b'x'; 256 * 1024]))
2317 } else {
2318 executor.execute(&command)
2319 }
2320 .unwrap();
2321 assert_eq!(output.stdout.len(), 131072);
2322 let pid: i32 = fs::read_to_string(pid_file)
2323 .unwrap()
2324 .trim()
2325 .parse()
2326 .unwrap();
2327 let deadline = Instant::now() + Duration::from_secs(2);
2328 loop {
2329 let state = fs::read_to_string(format!("/proc/{pid}/stat")).ok();
2330 if state.as_ref().is_none_or(|state| {
2331 state
2332 .rsplit_once(") ")
2333 .is_some_and(|(_, fields)| fields.starts_with('Z'))
2334 }) {
2335 break;
2336 }
2337 if Instant::now() >= deadline {
2338 unsafe {
2340 libc::kill(pid, libc::SIGKILL);
2341 }
2342 panic!(
2343 "successful command left descendant {pid} running (streamed={streamed})"
2344 );
2345 }
2346 std::thread::sleep(Duration::from_millis(10));
2347 }
2348 }
2349 }
2350
2351 #[cfg(target_os = "linux")]
2355 #[test]
2356 fn commands_complete_at_leader_exit_when_a_descendant_holds_the_pipes() {
2357 for streamed in [false, true] {
2358 let temp = tempfile::tempdir().unwrap();
2359 let pid_file = temp.path().join("descendant");
2360 let command = CommandSpec::new(
2361 "sh",
2362 [
2363 "-c".to_owned(),
2364 "sleep 300 & echo $! > \"$1\"; echo hi; exit 3".to_owned(),
2365 "held-pipes".to_owned(),
2366 pid_file.display().to_string(),
2367 ],
2368 );
2369 let executor = CancellableProcessExecutor::with_timeout(Duration::from_secs(30));
2370 let started = Instant::now();
2371 let output = if streamed {
2372 executor.execute_with_stdin(&command, &mut std::io::Cursor::new(b"input".to_vec()))
2373 } else {
2374 executor.execute(&command)
2375 }
2376 .unwrap();
2377 assert!(
2378 started.elapsed() < Duration::from_secs(5),
2379 "streamed={streamed} took {:?}",
2380 started.elapsed()
2381 );
2382 assert_eq!(output.status, 3);
2383 assert_eq!(output.stdout, b"hi\n");
2384 let pid: i32 = fs::read_to_string(pid_file)
2385 .unwrap()
2386 .trim()
2387 .parse()
2388 .unwrap();
2389 let deadline = Instant::now() + Duration::from_secs(2);
2390 loop {
2391 let state = fs::read_to_string(format!("/proc/{pid}/stat")).ok();
2392 if state.as_ref().is_none_or(|state| {
2393 state
2394 .rsplit_once(") ")
2395 .is_some_and(|(_, fields)| fields.starts_with('Z'))
2396 }) {
2397 break;
2398 }
2399 if Instant::now() >= deadline {
2400 unsafe {
2402 libc::kill(pid, libc::SIGKILL);
2403 }
2404 panic!("completed command left descendant {pid} (streamed={streamed})");
2405 }
2406 std::thread::sleep(Duration::from_millis(10));
2407 }
2408 }
2409 }
2410
2411 #[test]
2412 fn streamed_deadline_survives_leader_exit_and_inherited_pipes() {
2413 let command = CommandSpec::new(
2414 "sh",
2415 [
2416 "-c",
2417 "head -c 131072 /dev/zero; cat >/dev/null; (trap '' TERM; sleep 60) & exit 0",
2418 ],
2419 );
2420 let mut input = std::io::Cursor::new(vec![b'x'; 256 * 1024]);
2421 let started = Instant::now();
2422 let error = CancellableProcessExecutor::with_timeout(Duration::from_millis(300))
2423 .execute_with_stdin(&command, &mut input)
2424 .unwrap_err();
2425 assert!(error.to_string().contains("cancelled"), "{error:#}");
2426 assert!(started.elapsed() < Duration::from_secs(5));
2427 }
2428
2429 #[test]
2430 fn cleanup_timeout_replaces_an_expired_deadline_and_keeps_shared_cancellation() {
2431 let directory = tempfile::tempdir().unwrap();
2432 let marker = directory.path().join("completed");
2433 let command = CommandSpec::new(
2434 "sh",
2435 [
2436 "-c".to_owned(),
2437 "printf ran > \"$1\"".to_owned(),
2438 "cleanup-deadline".to_owned(),
2439 marker.display().to_string(),
2440 ],
2441 );
2442 let cancelled = Arc::new(AtomicBool::new(false));
2443 let executor =
2444 CancellableProcessExecutor::new(cancelled.clone()).with_deadline(Duration::ZERO);
2445
2446 let output = executor
2447 .execute_cleanup_with_timeout(&command, Duration::from_secs(5))
2448 .unwrap();
2449
2450 assert_eq!(output.status, 0);
2451 assert_eq!(fs::read_to_string(marker).unwrap(), "ran");
2452
2453 cancelled.store(true, Ordering::Release);
2454 let error = executor
2455 .execute_cleanup_with_timeout(&command, Duration::from_secs(5))
2456 .unwrap_err();
2457 assert!(
2458 error.to_string().contains("operation cancelled"),
2459 "{error:#}"
2460 );
2461 }
2462
2463 fn flaky_ssh_script(directory: &Path) -> CommandSpec {
2467 let counter = directory.join("attempts");
2468 let script = format!(
2469 "count=$(cat {counter} 2>/dev/null || echo 0)\n\
2470 echo $((count + 1)) > {counter}\n\
2471 if [ \"$count\" -eq 0 ]; then\n\
2472 echo 'kex_exchange_identification: Connection closed by 10.0.0.1 port 22' >&2\n\
2473 exit 255\n\
2474 fi\n\
2475 echo connected\n",
2476 counter = counter.display()
2477 );
2478 CommandSpec::new("sh", ["-c".to_owned(), script])
2479 .ssh_destination("build@10.0.0.1")
2480 .purpose("run the flaky SSH fixture")
2481 }
2482
2483 fn attempts(directory: &Path) -> u32 {
2484 fs::read_to_string(directory.join("attempts"))
2485 .expect("the fixture records its attempts")
2486 .trim()
2487 .parse()
2488 .expect("attempt count is a number")
2489 }
2490
2491 #[derive(Default)]
2496 struct MasterKilledOnce {
2497 running: std::cell::RefCell<BTreeSet<String>>,
2498 openers: std::cell::Cell<usize>,
2499 sessions: std::cell::RefCell<Vec<Vec<String>>>,
2500 }
2501
2502 impl CommandExecutor for MasterKilledOnce {
2503 fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
2504 let reply = |status: i32, stderr: &str| CommandOutput {
2505 status,
2506 stdout: Vec::new(),
2507 stderr: stderr.as_bytes().to_vec(),
2508 };
2509 if command.ssh_session.is_some() {
2510 return with_ssh_admission(command, self, &|| false, |spawned| {
2512 self.sessions.borrow_mut().push(spawned.args.clone());
2513 if self.sessions.borrow().len() == 1 {
2514 self.running.borrow_mut().clear();
2515 return Ok(reply(255, "Connection closed by UNKNOWN port 65535"));
2516 }
2517 Ok(reply(0, ""))
2518 });
2519 }
2520 let socket = command
2521 .args
2522 .iter()
2523 .find_map(|arg| arg.strip_prefix("ControlPath="))
2524 .expect("a master command names its socket")
2525 .to_owned();
2526 if command.args.windows(2).any(|pair| pair == ["-O", "check"]) {
2527 return Ok(reply(
2528 if self.running.borrow().contains(&socket) {
2529 0
2530 } else {
2531 255
2532 },
2533 "",
2534 ));
2535 }
2536 assert!(command.args.contains(&"ControlMaster=yes".to_owned()));
2537 self.openers.set(self.openers.get() + 1);
2538 self.running.borrow_mut().insert(socket);
2539 Ok(reply(0, ""))
2540 }
2541 }
2542
2543 #[test]
2544 fn a_session_whose_master_died_is_retried_on_a_reopened_master() {
2545 let _guard = ssh::SHARING_TEST_LOCK
2546 .lock()
2547 .unwrap_or_else(std::sync::PoisonError::into_inner);
2548 set_ssh_retry_backoff_for_test(Some(Duration::from_millis(5)));
2549 let socket_dir = tempfile::tempdir_in("/tmp").expect("short socket directory");
2550 set_ssh_connection_sharing_for_test(Some(SshSharingForTest::Directory(
2551 socket_dir.path().to_path_buf(),
2552 )));
2553 let ssh = SshTarget {
2554 destination: "master-killed-once-host".to_owned(),
2555 ssh_args: Vec::new(),
2556 };
2557 let executor = MasterKilledOnce::default();
2558 let output = executor.execute(&ssh_command(&ssh, ["true"]));
2559 set_ssh_connection_sharing_for_test(None);
2560 set_ssh_retry_backoff_for_test(None);
2561
2562 assert_eq!(output.expect("the retry succeeds").status, 0);
2563 let sessions = executor.sessions.borrow();
2564 assert_eq!(sessions.len(), 2);
2565 assert_eq!(
2566 executor.openers.get(),
2567 2,
2568 "the retry reopens the master instead of trusting the earlier check"
2569 );
2570 for args in sessions.iter() {
2571 assert_eq!(args[..6][5], "ProxyCommand=false", "{args:?}");
2572 }
2573 }
2574
2575 #[test]
2577 fn a_transport_rejected_ssh_command_is_retried_once_and_then_succeeds() {
2578 set_ssh_retry_backoff_for_test(Some(Duration::from_millis(5)));
2579 let directory = tempfile::tempdir().expect("temp dir");
2580 let command = flaky_ssh_script(directory.path());
2581
2582 let output = ProcessExecutor
2583 .execute(&command)
2584 .expect("the retry must reach the successful attempt");
2585
2586 assert_eq!(output.status, 0);
2587 assert_eq!(String::from_utf8_lossy(&output.stdout).trim(), "connected");
2588 assert_eq!(attempts(directory.path()), 2);
2589 set_ssh_retry_backoff_for_test(None);
2590 }
2591
2592 #[test]
2594 fn the_cancellable_executor_also_retries_a_transport_rejection() {
2595 set_ssh_retry_backoff_for_test(Some(Duration::from_millis(5)));
2596 let directory = tempfile::tempdir().expect("temp dir");
2597 let command = flaky_ssh_script(directory.path());
2598
2599 let output = CancellableProcessExecutor::new(Arc::new(AtomicBool::new(false)))
2600 .execute(&command)
2601 .expect("the retry must reach the successful attempt");
2602
2603 assert_eq!(output.status, 0);
2604 assert_eq!(attempts(directory.path()), 2);
2605 set_ssh_retry_backoff_for_test(None);
2606 }
2607}