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::{HarnessHost, HarnessKind, ImagePullPolicy};
20
21pub const SESSION_LABEL: &str = "dev.mj.session";
22pub const MANAGED_LABEL: &str = "dev.mj.managed";
23pub const SESSION_TAG: &str = "dev.mj.session";
24pub const MANAGED_TAG: &str = "dev.mj.managed";
25pub const INSTANCE_LABEL: &str = "dev.mj.instance";
28pub const INSTANCE_TAG: &str = "dev.mj.instance";
29pub const CONTAINER_WORKSPACE: &str = "/workspace";
33
34#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
36pub enum ProvisionStage {
37 PullingImage,
40 Provisioning,
41 Booting,
42 Cloning,
43 Syncing,
44 Restoring,
45 Starting,
46 Installing(HarnessKind),
47 Compacting,
48 RecoveryCopy,
49 Verifying,
50 Closing,
51 StoppingTarget,
52 RemovingContainer,
53 RemovingStorage,
54 CleaningCache,
55}
56
57impl ProvisionStage {
58 pub fn label(self) -> String {
59 match self {
60 Self::PullingImage => "Pull image".into(),
61 Self::Provisioning => "Provision".into(),
62 Self::Booting => "Boot".into(),
63 Self::Cloning => "Clone".into(),
64 Self::Syncing => "Sync".into(),
65 Self::Restoring => "Restore".into(),
66 Self::Starting => "Start".into(),
67 Self::Installing(harness) => format!("Installing {}", harness.display_name()),
68 Self::Compacting => "Compact".into(),
69 Self::RecoveryCopy => "Recovery copy".into(),
70 Self::Verifying => "Verify".into(),
71 Self::Closing => "Close".into(),
72 Self::StoppingTarget => "Stop target".into(),
73 Self::RemovingContainer => "Remove container".into(),
74 Self::RemovingStorage => "Remove container storage".into(),
75 Self::CleaningCache => "Clean cache".into(),
76 }
77 }
78}
79
80#[derive(Clone, PartialEq, Eq)]
81struct SensitiveCommandInput(Vec<u8>);
82
83impl std::fmt::Debug for SensitiveCommandInput {
84 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
85 formatter.write_str("<redacted>")
86 }
87}
88
89#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
90pub struct CommandSpec {
91 pub program: String,
92 pub args: Vec<String>,
93 #[serde(default)]
94 pub env: BTreeMap<String, String>,
95 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
97 pub clear_env: bool,
98 #[serde(default, skip_serializing_if = "Option::is_none")]
99 pub cwd: Option<std::path::PathBuf>,
100 pub purpose: String,
101 #[serde(default)]
102 pub stage: Option<ProvisionStage>,
103 #[serde(default)]
108 pub parallel_group: Option<u32>,
109 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
113 pub creates_target: bool,
114 #[serde(default, skip_serializing_if = "Option::is_none")]
119 pub ssh_destination: Option<String>,
120 #[serde(skip)]
123 sensitive_stdin: Option<SensitiveCommandInput>,
124}
125
126impl CommandSpec {
127 pub fn new(
128 program: impl Into<String>,
129 args: impl IntoIterator<Item = impl Into<String>>,
130 ) -> Self {
131 Self {
132 program: program.into(),
133 args: args.into_iter().map(Into::into).collect(),
134 env: BTreeMap::new(),
135 clear_env: false,
136 cwd: None,
137 purpose: String::new(),
138 stage: None,
139 parallel_group: None,
140 creates_target: false,
141 ssh_destination: None,
142 sensitive_stdin: None,
143 }
144 }
145
146 pub fn purpose(mut self, purpose: impl Into<String>) -> Self {
147 self.purpose = purpose.into();
148 self
149 }
150
151 pub fn stage(mut self, stage: ProvisionStage) -> Self {
152 self.stage = Some(stage);
153 self
154 }
155
156 pub fn parallel_group(mut self, group: u32) -> Self {
159 self.parallel_group = Some(group);
160 self
161 }
162
163 pub fn ssh_destination(mut self, destination: impl Into<String>) -> Self {
166 self.ssh_destination = Some(destination.into());
167 self
168 }
169
170 pub fn creates_target(mut self) -> Self {
172 self.creates_target = true;
173 self
174 }
175
176 pub fn with_sensitive_stdin(mut self, input: Vec<u8>) -> Self {
179 self.sensitive_stdin = Some(SensitiveCommandInput(input));
180 self
181 }
182}
183
184#[derive(Debug, Clone, PartialEq, Eq)]
185pub struct CommandOutput {
186 pub status: i32,
187 pub stdout: Vec<u8>,
188 pub stderr: Vec<u8>,
189}
190
191#[derive(Debug, Clone, PartialEq, Eq)]
192pub struct SessionResourceUsage {
193 pub cpu_percent: Option<u8>,
194 pub memory_current_bytes: u64,
195 pub memory_limit_bytes: Option<u64>,
196 pub swap_current_bytes: Option<u64>,
197 pub swap_limit_bytes: Option<u64>,
198 pub writable_disk_bytes: Option<u64>,
199}
200
201#[derive(Debug, Clone, PartialEq, Eq)]
202pub struct SessionResourceProbe {
203 pub memory: CommandSpec,
204 pub disk: Option<CommandSpec>,
205}
206
207#[derive(Debug, Clone, Copy, PartialEq, Eq)]
208pub enum DeploymentCapacityKind {
209 Host,
210 AwsFleet,
211}
212
213#[derive(Debug, Clone, PartialEq, Eq)]
214pub struct DeploymentCapacityTarget {
215 pub id: String,
216 pub host: String,
217 pub target_ids: Vec<String>,
218 pub kind: DeploymentCapacityKind,
219 pub local: bool,
220 pub probes: Vec<CommandSpec>,
222 pub probe_error: Option<String>,
224}
225
226#[derive(Debug, Clone, PartialEq, Eq)]
227pub struct DeploymentCapacityUsage {
228 pub cpu_percent: Option<u8>,
229 pub memory_used_bytes: u64,
230 pub memory_total_bytes: u64,
231 pub logical_cores: u64,
232 pub disk_total_bytes: Option<u64>,
233}
234
235#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
241#[serde(from = "AdditionalMountRepr", into = "AdditionalMountRepr")]
242pub struct AdditionalMount {
243 pub source: PathBuf,
244 pub destination: PathBuf,
245 pub access: MountAccess,
246}
247
248#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
250#[serde(rename_all = "snake_case")]
251pub enum MountAccess {
252 Ro,
254 Cow,
257 Rw,
259}
260
261impl MountAccess {
262 pub const ALL: [Self; 3] = [Self::Ro, Self::Cow, Self::Rw];
263
264 pub fn label(self) -> &'static str {
265 match self {
266 Self::Ro => "ro",
267 Self::Cow => "cow",
268 Self::Rw => "rw",
269 }
270 }
271
272 pub fn without_overlay(self) -> Self {
281 match self {
282 Self::Cow => Self::Ro,
283 kept => kept,
284 }
285 }
286
287 pub fn offered(overlay_available: bool) -> Vec<Self> {
290 Self::ALL
291 .into_iter()
292 .filter(|access| overlay_available || access.without_overlay() == *access)
293 .collect()
294 }
295}
296
297#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
300pub struct ImageUser {
301 pub uid: u32,
302 pub gid: u32,
303}
304
305pub fn podman_userns_option(image_user: Option<ImageUser>) -> Option<String> {
316 image_user.map(|ImageUser { uid, gid }| format!("--userns=keep-id:uid={uid},gid={gid}"))
317}
318
319#[derive(Serialize, Deserialize)]
324#[serde(deny_unknown_fields)]
325struct AdditionalMountRepr {
326 source: PathBuf,
327 destination: PathBuf,
328 #[serde(default)]
329 read_only: bool,
330 #[serde(default, skip_serializing_if = "Option::is_none")]
331 access: Option<MountAccess>,
332}
333
334impl From<AdditionalMountRepr> for AdditionalMount {
335 fn from(repr: AdditionalMountRepr) -> Self {
336 let access = repr.access.unwrap_or(if repr.read_only {
337 MountAccess::Ro
338 } else {
339 MountAccess::Cow
340 });
341 Self {
342 source: repr.source,
343 destination: repr.destination,
344 access,
345 }
346 }
347}
348
349impl From<AdditionalMount> for AdditionalMountRepr {
350 fn from(mount: AdditionalMount) -> Self {
351 Self {
352 source: mount.source,
353 destination: mount.destination,
354 read_only: mount.access == MountAccess::Ro,
355 access: (mount.access == MountAccess::Rw).then_some(MountAccess::Rw),
356 }
357 }
358}
359
360pub fn overlay_unsupported_filesystem(filesystem: &str) -> Option<&'static str> {
366 let name = filesystem.trim().to_ascii_lowercase();
367 if name == "fuse" || name == "fuseblk" || name.starts_with("fuse.") {
369 return Some("FUSE filesystem");
370 }
371 match name.as_str() {
372 "nfs" | "nfs4" | "cifs" | "smb2" | "smb3" | "9p" | "v9fs" | "virtiofs" | "ceph"
373 | "lustre" | "afs" | "glusterfs" | "ocfs2" | "gfs" | "gfs2" => Some("network filesystem"),
374 "msdos" | "vfat" | "fat" | "exfat" | "ntfs" | "ntfs3" => Some("no POSIX metadata"),
375 "overlayfs" => Some("overlay stacking limit"),
376 _ => None,
377 }
378}
379
380pub fn validate_mount_destination(path: &Path) -> Result<()> {
382 ensure!(
383 path.is_absolute()
384 && !path
385 .components()
386 .any(|part| part == std::path::Component::ParentDir),
387 "additional mount destination must be a safe absolute container path; ~ is not supported"
388 );
389 Ok(())
390}
391
392pub fn validate_additional_mounts(mounts: &[AdditionalMount]) -> Result<()> {
393 let mut destinations = BTreeSet::new();
394 for mount in mounts {
395 if !mount.source.is_absolute() || mount.source.as_os_str().is_empty() {
396 bail!("additional mount source must be an absolute directory path");
397 }
398 validate_mount_destination(&mount.destination)?;
399 if !destinations.insert(mount.destination.clone()) {
400 bail!(
401 "additional mount destination {:?} is configured more than once",
402 mount.destination
403 );
404 }
405 }
406 Ok(())
407}
408
409pub fn default_mount_destination(source: &Path, existing: &[AdditionalMount]) -> PathBuf {
411 let basename = source
412 .file_name()
413 .filter(|name| !name.is_empty())
414 .unwrap_or_else(|| std::ffi::OsStr::new("mount"));
415 let base = PathBuf::from("/mnt").join(basename);
416 if !existing.iter().any(|mount| mount.destination == base) {
417 return base;
418 }
419 for number in 2.. {
420 let candidate =
421 PathBuf::from("/mnt").join(format!("{}-{number}", basename.to_string_lossy()));
422 if !existing.iter().any(|mount| mount.destination == candidate) {
423 return candidate;
424 }
425 }
426 unreachable!("a finite mount list always has an unused numbered destination")
427}
428
429pub trait CommandExecutor {
430 fn execute(&self, command: &CommandSpec) -> Result<CommandOutput>;
431
432 fn cancellation_requested(&self) -> bool {
436 false
437 }
438
439 fn stage_started(&self, _stage: ProvisionStage) {}
443
444 fn stage_finished(&self, _stage: ProvisionStage) {}
447
448 fn notify_notice(&self, _notice: &str) {}
451
452 fn execute_with_stdin(
453 &self,
454 _command: &CommandSpec,
455 _input: &mut (dyn Read + Send),
456 ) -> Result<CommandOutput> {
457 bail!("this command executor does not support streamed stdin")
458 }
459}
460
461pub struct ProvisionStageGuard<'a, E: CommandExecutor + ?Sized> {
465 executor: &'a E,
466 stage: ProvisionStage,
467}
468
469impl<'a, E: CommandExecutor + ?Sized> ProvisionStageGuard<'a, E> {
470 pub fn new(executor: &'a E, stage: ProvisionStage) -> Self {
471 executor.stage_started(stage);
472 Self { executor, stage }
473 }
474}
475
476impl<E: CommandExecutor + ?Sized> Drop for ProvisionStageGuard<'_, E> {
477 fn drop(&mut self) {
478 self.executor.stage_finished(self.stage);
479 }
480}
481
482pub struct ProcessExecutor;
483
484fn with_ssh_admission(
494 command: &CommandSpec,
495 is_cancelled: &dyn Fn() -> bool,
496 mut run: impl FnMut() -> Result<CommandOutput>,
497) -> Result<CommandOutput> {
498 let Some(destination) = command.ssh_destination.as_deref() else {
499 return run();
500 };
501 for attempt in 1..=SSH_RETRY_ATTEMPTS {
502 let output = {
503 let _permit = SshAdmission::acquire(destination);
504 run()?
505 };
506 if attempt == SSH_RETRY_ATTEMPTS
507 || !is_transport_rejection(output.status, &String::from_utf8_lossy(&output.stderr))
508 {
509 return Ok(output);
510 }
511 let delay = ssh_retry_delay(attempt);
512 tracing::warn!(
513 destination,
514 purpose = command.purpose.as_str(),
515 attempt,
516 attempts = SSH_RETRY_ATTEMPTS,
517 delay_ms = delay.as_millis() as u64,
518 stderr = String::from_utf8_lossy(&output.stderr).trim(),
519 "ssh was refused by the server before authentication; retrying"
520 );
521 if !sleep_unless_cancelled(delay, is_cancelled) {
522 bail!("operation cancelled while {}", command.purpose);
523 }
524 }
525 unreachable!("the final attempt always returns");
526}
527
528fn sleep_unless_cancelled(delay: Duration, is_cancelled: &dyn Fn() -> bool) -> bool {
531 let deadline = Instant::now() + delay;
532 loop {
533 if is_cancelled() {
534 return false;
535 }
536 let remaining = deadline.saturating_duration_since(Instant::now());
537 if remaining.is_zero() {
538 return true;
539 }
540 std::thread::sleep(remaining.min(Duration::from_millis(50)));
541 }
542}
543
544pub fn trace_command_duration(command: &CommandSpec, started: Instant, status: i32) {
547 tracing::debug!(
548 purpose = command.purpose.as_str(),
549 program = command.program.as_str(),
550 status,
551 elapsed_ms = started.elapsed().as_millis() as u64,
552 "target command finished"
553 );
554}
555
556impl ProcessExecutor {
557 fn run_once(&self, command: &CommandSpec) -> Result<CommandOutput> {
559 if let Some(input) = &command.sensitive_stdin {
560 let mut input = std::io::Cursor::new(input.0.as_slice());
561 return stream_command_with_stdin(
563 configured_command(command),
564 command,
565 &mut input,
566 &|| false,
567 );
568 }
569 let started = Instant::now();
570 let output = configured_command(command)
571 .stdin(Stdio::null())
572 .output()
573 .with_context(|| format!("run {} for {}", command.program, command.purpose))?;
574 let status = output.status.code().unwrap_or(-1);
575 trace_command_duration(command, started, status);
576 Ok(CommandOutput {
577 status,
578 stdout: output.stdout,
579 stderr: output.stderr,
580 })
581 }
582}
583
584impl CommandExecutor for ProcessExecutor {
585 fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
586 with_ssh_admission(command, &|| false, || self.run_once(command))
587 }
588
589 fn execute_with_stdin(
590 &self,
591 command: &CommandSpec,
592 input: &mut (dyn Read + Send),
593 ) -> Result<CommandOutput> {
594 let _permit = command
597 .ssh_destination
598 .as_deref()
599 .map(SshAdmission::acquire);
600 let process = configured_command(command);
601 stream_command_with_stdin(process, command, input, &|| false)
604 }
605}
606
607fn stream_command_with_stdin(
616 mut process: Command,
617 command: &CommandSpec,
618 input: &mut (dyn Read + Send),
619 is_cancelled: &(dyn Fn() -> bool + Sync),
620) -> Result<CommandOutput> {
621 let started = Instant::now();
622 if is_cancelled() {
623 bail!("operation cancelled");
624 }
625 let mut child = process
626 .stdin(Stdio::piped())
627 .stdout(Stdio::piped())
628 .stderr(Stdio::piped())
629 .spawn()
630 .with_context(|| format!("run {} for {}", command.program, command.purpose))?;
631 let stdin = child
632 .stdin
633 .take()
634 .context("streamed command stdin missing")?;
635 let mut stdout = child
636 .stdout
637 .take()
638 .context("streamed command stdout missing")?;
639 let mut stderr = child
640 .stderr
641 .take()
642 .context("streamed command stderr missing")?;
643 let stdout_reader = std::thread::spawn(move || {
646 let mut bytes = Vec::new();
647 std::io::copy(&mut stdout, &mut bytes).map(|_| bytes)
648 });
649 let stderr_reader = std::thread::spawn(move || {
650 let mut bytes = Vec::new();
651 std::io::copy(&mut stderr, &mut bytes).map(|_| bytes)
652 });
653 let process_result = std::thread::scope(|scope| -> Result<_> {
654 let input_writer = scope.spawn(move || -> Result<()> {
658 let mut stdin = stdin;
663 let mut buffer = [0_u8; 64 * 1024];
664 loop {
665 if is_cancelled() {
669 bail!("operation cancelled");
670 }
671 let count = input.read(&mut buffer).context("read command input")?;
672 if count == 0 {
673 break;
674 }
675 stdin
676 .write_all(&buffer[..count])
677 .context("stream command input")?;
678 }
679 stdin.flush().context("flush command input")
680 });
681 let status = loop {
682 if is_cancelled() {
683 terminate_cancellable_child(&mut child);
684 if let Err(error) = input_writer.join() {
685 tracing::warn!(
686 purpose = command.purpose.as_str(),
687 "streamed command input writer panicked while cancelling: {error:?}"
688 );
689 }
690 bail!("operation cancelled while {}", command.purpose);
691 }
692 match child.try_wait() {
693 Ok(Some(status)) => break status,
694 Ok(None) => std::thread::sleep(Duration::from_millis(25)),
695 Err(error) => {
696 terminate_cancellable_child(&mut child);
697 if let Err(join_error) = input_writer.join() {
698 tracing::warn!(
699 purpose = command.purpose.as_str(),
700 "streamed command input writer panicked while waiting: {join_error:?}"
701 );
702 }
703 return Err(error).with_context(|| format!("wait for {}", command.purpose));
704 }
705 }
706 };
707 let input_result = input_writer
708 .join()
709 .map_err(|_| anyhow::anyhow!("streamed command input writer panicked"))?;
710 Ok((status, input_result))
711 });
712 let stdout = stdout_reader
713 .join()
714 .map_err(|_| anyhow::anyhow!("streamed command stdout reader panicked"))??;
715 let stderr = stderr_reader
716 .join()
717 .map_err(|_| anyhow::anyhow!("streamed command stderr reader panicked"))??;
718 let (status, input_result) = process_result?;
719 if status.success() {
720 input_result?;
724 }
725 let status = status.code().unwrap_or(-1);
726 trace_command_duration(command, started, status);
727 Ok(CommandOutput {
728 status,
729 stdout,
730 stderr,
731 })
732}
733
734#[derive(Clone)]
735pub struct CancellableProcessExecutor {
736 cancelled: Arc<AtomicBool>,
737 deadline: Option<Instant>,
738}
739
740impl CancellableProcessExecutor {
741 pub fn new(cancelled: Arc<AtomicBool>) -> Self {
742 Self {
743 cancelled,
744 deadline: None,
745 }
746 }
747
748 pub fn is_cancelled(&self) -> bool {
749 self.cancelled.load(Ordering::Acquire)
750 || self
751 .deadline
752 .is_some_and(|deadline| Instant::now() >= deadline)
753 }
754
755 pub fn with_timeout(timeout: Duration) -> Self {
756 Self {
757 cancelled: Arc::new(AtomicBool::new(false)),
758 deadline: Some(Instant::now() + timeout),
759 }
760 }
761
762 pub fn with_deadline(mut self, timeout: Duration) -> Self {
765 self.deadline = Some(Instant::now() + timeout);
766 self
767 }
768
769 fn check_cancelled(&self) -> Result<()> {
770 if self.is_cancelled() {
771 bail!("operation cancelled");
772 }
773 Ok(())
774 }
775}
776
777fn configured_command(command: &CommandSpec) -> Command {
778 let mut process = Command::new(&command.program);
779 if command.clear_env {
780 process.env_clear();
781 }
782 if let Some(cwd) = &command.cwd {
783 process.current_dir(cwd);
784 }
785 process.args(&command.args).envs(&command.env);
786 process
787}
788
789fn cancellable_command(command: &CommandSpec) -> Command {
790 #[cfg(unix)]
791 let mut process = configured_command(command);
792 #[cfg(not(unix))]
793 let process = configured_command(command);
794 #[cfg(unix)]
795 {
796 use std::os::unix::process::CommandExt as _;
797 process.process_group(0);
798 }
799 process
800}
801
802fn terminate_cancellable_child(child: &mut std::process::Child) {
803 #[cfg(unix)]
804 if let Err(error) = crate::subprocess::signal_process_group(child.id() as i32, libc::SIGKILL) {
809 tracing::warn!(pid = child.id(), %error, "could not terminate cancelled command process group");
810 }
811 #[cfg(not(unix))]
812 if let Err(error) = child.kill() {
813 tracing::warn!(pid = child.id(), %error, "could not terminate cancelled command");
814 }
815 if let Err(error) = child.wait() {
816 tracing::warn!(pid = child.id(), %error, "could not reap cancelled command");
817 }
818}
819
820impl CancellableProcessExecutor {
821 fn run_once(&self, command: &CommandSpec) -> Result<CommandOutput> {
823 if let Some(input) = &command.sensitive_stdin {
824 let mut input = std::io::Cursor::new(input.0.as_slice());
825 return stream_command_with_stdin(
827 cancellable_command(command),
828 command,
829 &mut input,
830 &|| self.is_cancelled(),
831 );
832 }
833 let started = Instant::now();
834 self.check_cancelled()?;
835 let mut child = cancellable_command(command)
836 .stdin(Stdio::null())
837 .stdout(Stdio::piped())
838 .stderr(Stdio::piped())
839 .spawn()
840 .with_context(|| format!("run {} for {}", command.program, command.purpose))?;
841 let mut stdout = child.stdout.take().context("command stdout missing")?;
842 let mut stderr = child.stderr.take().context("command stderr missing")?;
843 let stdout_reader = std::thread::spawn(move || {
844 let mut bytes = Vec::new();
845 std::io::copy(&mut stdout, &mut bytes).map(|_| bytes)
846 });
847 let stderr_reader = std::thread::spawn(move || {
848 let mut bytes = Vec::new();
849 std::io::copy(&mut stderr, &mut bytes).map(|_| bytes)
850 });
851 let mut status = None;
852 let status = loop {
853 if self.is_cancelled() {
854 terminate_cancellable_child(&mut child);
855 for (stream, reader) in [("stdout", stdout_reader), ("stderr", stderr_reader)] {
856 match reader.join() {
857 Ok(Ok(_)) => {}
858 Ok(Err(error)) => {
859 tracing::warn!(stream, %error, "cancelled command reader failed")
860 }
861 Err(_) => tracing::warn!(stream, "cancelled command reader panicked"),
862 }
863 }
864 bail!("operation cancelled while {}", command.purpose);
865 }
866 if status.is_none() {
867 status = child
868 .try_wait()
869 .with_context(|| format!("wait for {}", command.purpose))?;
870 }
871 if let Some(status) = status
874 && stdout_reader.is_finished()
875 && stderr_reader.is_finished()
876 {
877 break status;
878 }
879 std::thread::sleep(Duration::from_millis(25));
880 };
881 let stdout = stdout_reader
882 .join()
883 .map_err(|_| anyhow::anyhow!("command stdout reader panicked"))??;
884 let stderr = stderr_reader
885 .join()
886 .map_err(|_| anyhow::anyhow!("command stderr reader panicked"))??;
887 let status = status.code().unwrap_or(-1);
888 trace_command_duration(command, started, status);
889 Ok(CommandOutput {
890 status,
891 stdout,
892 stderr,
893 })
894 }
895}
896
897impl CommandExecutor for CancellableProcessExecutor {
898 fn cancellation_requested(&self) -> bool {
899 self.is_cancelled()
900 }
901
902 fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
903 with_ssh_admission(command, &|| self.is_cancelled(), || self.run_once(command))
904 }
905
906 fn execute_with_stdin(
907 &self,
908 command: &CommandSpec,
909 input: &mut (dyn Read + Send),
910 ) -> Result<CommandOutput> {
911 let _permit = command
914 .ssh_destination
915 .as_deref()
916 .map(SshAdmission::acquire);
917 stream_command_with_stdin(cancellable_command(command), command, input, &|| {
920 self.is_cancelled()
921 })
922 }
923}
924
925#[derive(Debug, Clone, PartialEq, Eq)]
929pub struct CommandTimedOut {
930 pub program: String,
931 pub purpose: String,
932 pub timeout: Duration,
933}
934
935impl std::fmt::Display for CommandTimedOut {
936 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
937 write!(
938 formatter,
939 "`{}` did not answer within {} seconds while trying to {}",
940 self.program,
941 self.timeout.as_secs(),
942 self.purpose
943 )
944 }
945}
946
947impl std::error::Error for CommandTimedOut {}
948
949#[derive(Debug, Clone, Copy)]
958pub struct BoundedProcessExecutor {
959 timeout: Duration,
960}
961
962impl BoundedProcessExecutor {
963 pub const fn new(timeout: Duration) -> Self {
964 Self { timeout }
965 }
966}
967
968impl CommandExecutor for BoundedProcessExecutor {
969 fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
970 let executor = CancellableProcessExecutor::with_timeout(self.timeout);
971 executor.execute(command).map_err(|error| {
972 if executor.is_cancelled() {
973 anyhow::Error::new(CommandTimedOut {
974 program: command.program.clone(),
975 purpose: command.purpose.clone(),
976 timeout: self.timeout,
977 })
978 } else {
979 error
980 }
981 })
982 }
983
984 fn execute_with_stdin(
985 &self,
986 command: &CommandSpec,
987 input: &mut (dyn Read + Send),
988 ) -> Result<CommandOutput> {
989 CancellableProcessExecutor::with_timeout(self.timeout).execute_with_stdin(command, input)
990 }
991}
992
993#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
994pub struct CommandPlan {
995 pub description: String,
996 pub commands: Vec<CommandSpec>,
997}
998
999impl CommandPlan {
1000 pub fn provide_target_environment_secret(
1004 &mut self,
1005 target: &TargetTemplate,
1006 name: &str,
1007 value: &str,
1008 ) -> Result<()> {
1009 ensure!(
1010 !name.is_empty()
1011 && name.bytes().enumerate().all(|(index, byte)| byte == b'_'
1012 || byte.is_ascii_alphabetic()
1013 || (index > 0 && byte.is_ascii_digit())),
1014 "invalid secret environment variable name"
1015 );
1016 ensure!(
1017 !value.as_bytes().contains(&b'\n') && !value.as_bytes().contains(&b'\r'),
1018 "secret environment value cannot contain a newline"
1019 );
1020 let command = self
1021 .commands
1022 .iter_mut()
1023 .find(|command| command.creates_target)
1024 .context("provisioning plan has no target creation command")?;
1025 let read_and_export = format!("IFS= read -r {name} || exit 1; export {name};");
1026 match target {
1027 TargetTemplate::LocalPodman(_)
1028 | TargetTemplate::LocalDocker(_)
1029 | TargetTemplate::AppleContainer(_) => {
1030 let program = std::mem::replace(&mut command.program, "sh".to_owned());
1031 let args = std::mem::take(&mut command.args);
1032 command.args = vec![
1033 "-c".to_owned(),
1034 format!("{read_and_export} exec \"$@\""),
1035 "mj-secret-env".to_owned(),
1036 program,
1037 ];
1038 command.args.extend(args);
1039 }
1040 TargetTemplate::SshPodman { .. } | TargetTemplate::SshDocker { .. } => {
1041 let remote = command
1042 .args
1043 .last_mut()
1044 .context("remote container command has no SSH command argument")?;
1045 *remote = format!("{read_and_export} exec {remote}");
1046 }
1047 TargetTemplate::LocalBare
1048 | TargetTemplate::AwsEc2(_)
1049 | TargetTemplate::SshBare { .. } => {
1050 bail!("target does not support inherited container environment")
1051 }
1052 }
1053 let mut input = value.as_bytes().to_vec();
1054 input.push(b'\n');
1055 command.sensitive_stdin = Some(SensitiveCommandInput(input));
1056 Ok(())
1057 }
1058
1059 pub fn execute(&self, executor: &impl CommandExecutor) -> Result<Vec<CommandOutput>> {
1060 let mut outputs = Vec::with_capacity(self.commands.len());
1061 for command in &self.commands {
1062 let output = executor.execute(command)?;
1063 if output.status != 0 {
1064 bail!(
1065 "{} failed with status {}: {}",
1066 command.purpose,
1067 output.status,
1068 String::from_utf8_lossy(&output.stderr)
1069 );
1070 }
1071 outputs.push(output);
1072 }
1073 Ok(outputs)
1074 }
1075
1076 pub fn execute_concurrent(
1088 &self,
1089 executor: &(impl CommandExecutor + Sync),
1090 ) -> Result<Vec<CommandOutput>> {
1091 let mut outputs = Vec::with_capacity(self.commands.len());
1092 let mut index = 0;
1093 while index < self.commands.len() {
1094 let group = self.commands[index].parallel_group;
1095 let mut end = index + 1;
1096 if group.is_some() {
1097 while end < self.commands.len() && self.commands[end].parallel_group == group {
1098 end += 1;
1099 }
1100 }
1101 let batch = &self.commands[index..end];
1102 if let [command] = batch {
1103 outputs.push(checked_command_output(command, executor.execute(command)?)?);
1104 } else {
1105 let results: Vec<Result<CommandOutput>> = std::thread::scope(|scope| {
1106 let handles: Vec<_> = batch
1107 .iter()
1108 .map(|command| scope.spawn(|| executor.execute(command)))
1109 .collect();
1110 handles
1111 .into_iter()
1112 .map(|handle| match handle.join() {
1113 Ok(result) => result,
1114 Err(panic) => Err(anyhow::anyhow!(
1115 "concurrent command thread panicked: {}",
1116 command_thread_panic_message(panic.as_ref())
1117 )),
1118 })
1119 .collect()
1120 });
1121 for (command, result) in batch.iter().zip(results) {
1122 outputs.push(checked_command_output(command, result?)?);
1123 }
1124 }
1125 index = end;
1126 }
1127 Ok(outputs)
1128 }
1129
1130 pub fn split_at_target_creation(&self) -> Option<(Self, Self)> {
1138 let created = self
1139 .commands
1140 .iter()
1141 .position(|command| command.creates_target)?;
1142 let (creation, remainder) = self.commands.split_at(created + 1);
1143 Some((
1144 Self {
1145 description: self.description.clone(),
1146 commands: creation.to_vec(),
1147 },
1148 Self {
1149 description: self.description.clone(),
1150 commands: remainder.to_vec(),
1151 },
1152 ))
1153 }
1154}
1155
1156pub fn checked_command_output(
1160 command: &CommandSpec,
1161 output: CommandOutput,
1162) -> Result<CommandOutput> {
1163 if output.status != 0 {
1164 bail!(
1165 "{} failed with status {}: {}",
1166 command.purpose,
1167 output.status,
1168 String::from_utf8_lossy(&output.stderr)
1169 );
1170 }
1171 Ok(output)
1172}
1173
1174pub fn command_thread_panic_message(payload: &(dyn std::any::Any + Send)) -> String {
1176 if let Some(message) = payload.downcast_ref::<&str>() {
1177 (*message).to_owned()
1178 } else if let Some(message) = payload.downcast_ref::<String>() {
1179 message.clone()
1180 } else {
1181 "non-string panic payload".to_owned()
1182 }
1183}
1184
1185#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1186pub struct RepositorySpec {
1187 pub url: Option<String>,
1189 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1190 pub push_urls: Vec<String>,
1191 pub destination: String,
1192 pub git_ref: Option<String>,
1193 #[serde(default, skip_serializing_if = "Option::is_none")]
1196 pub reference: Option<String>,
1197}
1198
1199#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1200pub struct ProjectBundleSpec {
1201 pub primary: String,
1202 pub repositories: Vec<RepositorySpec>,
1203}
1204
1205impl ProjectBundleSpec {
1206 pub fn validate(&self) -> Result<()> {
1207 validate_relative_path(&self.primary)?;
1208 if self.repositories.is_empty() {
1209 bail!("a project bundle must contain at least one repository");
1210 }
1211 let mut destinations = std::collections::BTreeSet::new();
1212 for repository in &self.repositories {
1213 validate_relative_path(&repository.destination)?;
1214 ensure!(
1215 repository
1216 .url
1217 .as_deref()
1218 .is_some_and(|url| !url.trim().is_empty() && !url.starts_with('-')),
1219 "isolated repositories require a network Git remote; configure a remote or use a raw local session"
1220 );
1221 crate::remote_git::validate_network_url(
1222 repository.url.as_deref().expect("checked above"),
1223 )?;
1224 for push_url in &repository.push_urls {
1225 crate::remote_git::validate_network_url(push_url)?;
1226 }
1227 ensure!(
1228 repository.git_ref.is_none(),
1229 "git_ref is no longer supported; remove it to start from the remote's default branch"
1230 );
1231 if !destinations.insert(&repository.destination) {
1232 bail!(
1233 "duplicate repository destination {}",
1234 repository.destination
1235 );
1236 }
1237 }
1238 if !destinations.contains(&self.primary) {
1239 bail!("primary repository is not present in the bundle");
1240 }
1241 Ok(())
1242 }
1243}
1244
1245#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1246#[serde(tag = "kind", rename_all = "snake_case")]
1247pub enum PodmanWorkspaceStorage {
1248 PodmanVolume,
1249 HostHelper {
1250 root: String,
1251 helper: Vec<String>,
1252 },
1253 #[default]
1254 ContainerLayer,
1255}
1256
1257#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1258pub struct ContainerTemplate {
1259 pub image: String,
1260 #[serde(default)]
1261 pub pull_policy: ImagePullPolicy,
1262 #[serde(default)]
1263 pub extra_run_args: Vec<String>,
1264 #[serde(default)]
1265 pub workspace_storage: PodmanWorkspaceStorage,
1266 #[serde(default)]
1269 pub build_cache: Option<crate::config::TargetBuildCache>,
1270}
1271
1272impl ImagePullPolicy {
1273 pub fn resolve(self, image: &str) -> Self {
1276 if self != Self::Auto {
1277 return self;
1278 }
1279 if image_is_digest_pinned(image) {
1280 Self::Missing
1281 } else if image_is_remote(image) && image_uses_latest_tag(image) {
1282 Self::Newer
1283 } else {
1284 Self::Missing
1285 }
1286 }
1287
1288 pub fn at_launch(self, image: &str) -> Self {
1293 if self == Self::Auto {
1294 Self::Missing
1295 } else {
1296 self.resolve(image)
1297 }
1298 }
1299
1300 pub fn describe(self, image: &str) -> &'static str {
1305 match self {
1306 Self::Always => "Pull every launch",
1307 Self::Newer => "Pull when the registry is newer",
1308 Self::Missing => "Pull only if missing",
1309 Self::Never => "Never pull",
1310 Self::Auto => match self.resolve(image) {
1311 Self::Newer => "Pull if missing at launch; refresh :latest in background",
1312 _ => "Pull if missing",
1313 },
1314 }
1315 }
1316
1317 pub fn podman_value(self) -> &'static str {
1319 match self {
1320 Self::Always => "always",
1321 Self::Newer => "newer",
1322 Self::Missing => "missing",
1323 Self::Never => "never",
1324 Self::Auto => unreachable!("auto pull policy must resolve"),
1325 }
1326 }
1327}
1328
1329#[derive(Debug, Clone, PartialEq, Eq)]
1335pub enum ImageHost {
1336 LocalPodman,
1337 LocalDocker,
1338 AppleContainer,
1339 SshPodman(SshTarget),
1340 SshDocker(SshTarget),
1341}
1342
1343impl ImageHost {
1344 pub const fn engine(&self) -> &'static str {
1345 match self {
1346 Self::LocalPodman | Self::SshPodman(_) => "podman",
1347 Self::LocalDocker | Self::SshDocker(_) => "docker",
1348 Self::AppleContainer => "container",
1349 }
1350 }
1351
1352 pub fn label(&self) -> String {
1354 match self {
1355 Self::LocalPodman => "local podman".to_owned(),
1356 Self::LocalDocker => "local docker".to_owned(),
1357 Self::AppleContainer => "apple container".to_owned(),
1358 Self::SshPodman(ssh) => format!("podman on {}", ssh.destination),
1359 Self::SshDocker(ssh) => format!("docker on {}", ssh.destination),
1360 }
1361 }
1362
1363 fn command(&self, args: Vec<String>, purpose: String) -> CommandSpec {
1364 match self {
1365 Self::LocalPodman | Self::LocalDocker | Self::AppleContainer => {
1366 CommandSpec::new(args[0].clone(), args[1..].iter().cloned())
1367 }
1368 Self::SshPodman(ssh) | Self::SshDocker(ssh) => ssh_command_owned(ssh, args),
1369 }
1370 .purpose(purpose)
1371 }
1372}
1373
1374#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
1381pub enum RefreshWhen {
1382 WhenAbsent,
1384 Always,
1386}
1387
1388#[derive(Debug, Clone, PartialEq, Eq)]
1391pub struct ImageRefresh {
1392 pub host: ImageHost,
1393 pub image: String,
1394 pub platform: Option<String>,
1395 pub when: RefreshWhen,
1398 pub image_id: CommandSpec,
1401 pub pull: CommandSpec,
1402 pub prune: Option<CommandSpec>,
1405}
1406
1407pub fn image_refresh(
1414 host: ImageHost,
1415 image: &str,
1416 platform: Option<&str>,
1417 pull_policy: ImagePullPolicy,
1418) -> Option<ImageRefresh> {
1419 let when = match pull_policy.resolve(image) {
1420 ImagePullPolicy::Always | ImagePullPolicy::Newer => RefreshWhen::Always,
1421 ImagePullPolicy::Missing => RefreshWhen::WhenAbsent,
1422 ImagePullPolicy::Never => return None,
1423 ImagePullPolicy::Auto => unreachable!("auto pull policy must resolve"),
1424 };
1425 let engine = host.engine();
1426 let apple = matches!(host, ImageHost::AppleContainer);
1430 let mut image_id_args = vec![engine.to_owned(), "image".to_owned(), "inspect".to_owned()];
1431 if !apple {
1432 image_id_args.push("--format".to_owned());
1433 image_id_args.push("{{.Id}}".to_owned());
1434 }
1435 image_id_args.push(image.to_owned());
1436 let image_id = host.command(
1437 image_id_args,
1438 format!("read the cached id of container image {image}"),
1439 );
1440 let mut pull_args = vec![engine.to_owned()];
1441 if apple {
1442 pull_args.push("image".to_owned());
1443 }
1444 pull_args.push("pull".to_owned());
1445 if let Some(platform) = platform.filter(|_| !apple) {
1447 pull_args.push(format!("--platform={platform}"));
1448 }
1449 pull_args.push(image.to_owned());
1450 let pull = host.command(pull_args, format!("refresh container image {image}"));
1451 let prune = (!apple).then(|| {
1452 host.command(
1453 vec![
1454 engine.to_owned(),
1455 "image".to_owned(),
1456 "prune".to_owned(),
1457 "-f".to_owned(),
1458 ],
1459 "remove dangling container images".to_owned(),
1460 )
1461 });
1462 Some(ImageRefresh {
1463 host,
1464 image: image.to_owned(),
1465 platform: platform.map(str::to_owned),
1466 when,
1467 image_id,
1468 pull,
1469 prune,
1470 })
1471}
1472
1473fn image_is_digest_pinned(image: &str) -> bool {
1474 image
1475 .rsplit_once('@')
1476 .is_some_and(|(_, digest)| !digest.is_empty())
1477}
1478
1479fn image_is_remote(image: &str) -> bool {
1480 !image.starts_with("localhost/") && !image.starts_with("local/")
1481}
1482
1483fn image_uses_latest_tag(image: &str) -> bool {
1484 let name = image.split_once('@').map_or(image, |(name, _)| name);
1485 let final_component = name.rsplit('/').next().unwrap_or(name);
1486 !final_component.contains(':') || final_component.ends_with(":latest")
1487}
1488
1489#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1490pub struct SshTarget {
1491 pub destination: String,
1492 #[serde(default)]
1493 pub ssh_args: Vec<String>,
1494}
1495
1496#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1497pub struct AwsTemplate {
1498 pub profile: String,
1499 pub region: String,
1500 pub launch_template: String,
1501 pub launch_template_version: Option<String>,
1502 pub instance_type: Option<String>,
1503 pub ssh: SshTarget,
1504}
1505
1506#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1507#[serde(tag = "kind", rename_all = "snake_case")]
1508pub enum TargetTemplate {
1509 LocalBare,
1510 LocalPodman(ContainerTemplate),
1511 LocalDocker(ContainerTemplate),
1512 AppleContainer(ContainerTemplate),
1513 AwsEc2(AwsTemplate),
1514 SshBare {
1515 ssh: SshTarget,
1516 #[serde(default = "default_ssh_prefix")]
1517 workspace_prefix: String,
1518 },
1519 SshPodman {
1520 ssh: SshTarget,
1521 container: ContainerTemplate,
1522 },
1523 SshDocker {
1524 ssh: SshTarget,
1525 container: ContainerTemplate,
1526 },
1527}
1528
1529fn default_ssh_prefix() -> String {
1530 ".local/share/hel/workspaces".to_owned()
1531}
1532
1533#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1534#[serde(tag = "kind", rename_all = "snake_case")]
1535pub enum PodmanWorkspaceLocator {
1536 #[default]
1537 ContainerLayer,
1538 Volume {
1539 name: String,
1540 },
1541 HostPath {
1542 path: String,
1543 helper: Vec<String>,
1544 resource: String,
1545 },
1546}
1547
1548#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1549#[serde(tag = "kind", rename_all = "snake_case")]
1550pub enum TargetLocator {
1551 LocalBare {
1552 worker_root: String,
1553 },
1554 LocalPodman {
1555 container_id: String,
1556 #[serde(default)]
1557 workspace_storage: PodmanWorkspaceLocator,
1558 #[serde(default, skip_serializing_if = "Option::is_none")]
1562 borrowed_from: Option<String>,
1563 },
1564 LocalDocker {
1565 container_id: String,
1566 #[serde(default, skip_serializing_if = "Option::is_none")]
1570 borrowed_from: Option<String>,
1571 },
1572 AppleContainer {
1573 container_id: String,
1574 #[serde(default, skip_serializing_if = "Option::is_none")]
1578 borrowed_from: Option<String>,
1579 },
1580 AwsEc2 {
1581 profile: String,
1582 region: String,
1583 instance_id: String,
1584 ssh: SshTarget,
1585 workspace: String,
1586 },
1587 SshBare {
1588 ssh: SshTarget,
1589 workspace: String,
1590 #[serde(default, skip_serializing_if = "Option::is_none")]
1592 worker_id: Option<String>,
1593 },
1594 SshPodman {
1595 ssh: SshTarget,
1596 container_id: String,
1597 #[serde(default)]
1598 workspace_storage: PodmanWorkspaceLocator,
1599 #[serde(default, skip_serializing_if = "Option::is_none")]
1603 borrowed_from: Option<String>,
1604 },
1605 SshDocker {
1606 ssh: SshTarget,
1607 container_id: String,
1608 #[serde(default, skip_serializing_if = "Option::is_none")]
1612 borrowed_from: Option<String>,
1613 },
1614}
1615
1616impl TargetTemplate {
1617 pub const fn container_engine(&self) -> Option<&'static str> {
1618 match self {
1619 Self::LocalPodman(_) | Self::SshPodman { .. } => Some("podman"),
1620 Self::LocalDocker(_) | Self::SshDocker { .. } => Some("docker"),
1621 Self::AppleContainer(_) => Some("container"),
1622 _ => None,
1623 }
1624 }
1625
1626 pub fn image_host(&self) -> Option<(ImageHost, &ContainerTemplate)> {
1629 match self {
1630 Self::LocalPodman(container) => Some((ImageHost::LocalPodman, container)),
1631 Self::LocalDocker(container) => Some((ImageHost::LocalDocker, container)),
1632 Self::AppleContainer(container) => Some((ImageHost::AppleContainer, container)),
1633 Self::SshPodman { ssh, container } => {
1634 Some((ImageHost::SshPodman(ssh.clone()), container))
1635 }
1636 Self::SshDocker { ssh, container } => {
1637 Some((ImageHost::SshDocker(ssh.clone()), container))
1638 }
1639 Self::LocalBare | Self::AwsEc2(_) | Self::SshBare { .. } => None,
1640 }
1641 }
1642}
1643
1644impl TargetLocator {
1645 pub const fn harness_host(&self) -> HarnessHost {
1649 match self {
1650 Self::LocalBare { .. } => HarnessHost::current(),
1651 _ => HarnessHost::Other,
1652 }
1653 }
1654
1655 pub const fn kind_name(&self) -> &'static str {
1657 match self {
1658 Self::LocalBare { .. } => "local-bare",
1659 Self::LocalPodman { .. } => "local-podman",
1660 Self::LocalDocker { .. } => "local-docker",
1661 Self::AppleContainer { .. } => "apple-container",
1662 Self::AwsEc2 { .. } => "aws-ec2",
1663 Self::SshBare { .. } => "ssh-bare",
1664 Self::SshPodman { .. } => "ssh-podman",
1665 Self::SshDocker { .. } => "ssh-docker",
1666 }
1667 }
1668
1669 pub const fn container_engine(&self) -> Option<&'static str> {
1670 match self {
1671 Self::LocalPodman { .. } | Self::SshPodman { .. } => Some("podman"),
1672 Self::LocalDocker { .. } | Self::SshDocker { .. } => Some("docker"),
1673 Self::AppleContainer { .. } => Some("container"),
1674 _ => None,
1675 }
1676 }
1677}
1678
1679#[derive(Debug, Clone, PartialEq, Eq)]
1683pub struct TargetRecoveryPlan {
1684 pub exists: CommandSpec,
1685 pub inspect: CommandSpec,
1686 pub start: CommandSpec,
1687 pub session_id: String,
1688}
1689
1690#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1691pub enum TargetRecoveryOutcome {
1692 NotRequired,
1693 Missing,
1694 AlreadyRunning,
1695 Started,
1696}
1697
1698pub fn resource_name(session_id: &str) -> Result<String> {
1699 validate_session_id(session_id)?;
1700 let readable: String = session_id
1701 .chars()
1702 .filter(|character| character.is_ascii_alphanumeric())
1703 .take(12)
1704 .map(|character| character.to_ascii_lowercase())
1705 .collect();
1706 let digest = Sha256::digest(session_id.as_bytes());
1707 Ok(format!(
1708 "mj-{readable}-{:02x}{:02x}{:02x}",
1709 digest[0], digest[1], digest[2]
1710 ))
1711}
1712
1713pub fn podman_workspace_locator(
1714 template: &ContainerTemplate,
1715 session_id: &str,
1716) -> Result<PodmanWorkspaceLocator> {
1717 let resource = format!("{}-workspace", resource_name(session_id)?);
1718 match &template.workspace_storage {
1719 PodmanWorkspaceStorage::PodmanVolume => {
1720 Ok(PodmanWorkspaceLocator::Volume { name: resource })
1721 }
1722 PodmanWorkspaceStorage::HostHelper { root, helper } => {
1723 let root = Path::new(root);
1724 ensure!(
1725 root.is_absolute(),
1726 "Podman workspace storage root must be absolute"
1727 );
1728 ensure!(
1729 !helper.is_empty() && helper.iter().all(|argument| !argument.is_empty()),
1730 "Podman workspace storage helper must contain non-empty arguments"
1731 );
1732 Ok(PodmanWorkspaceLocator::HostPath {
1733 path: root.join(&resource).to_string_lossy().into_owned(),
1734 helper: helper.clone(),
1735 resource,
1736 })
1737 }
1738 PodmanWorkspaceStorage::ContainerLayer => Ok(PodmanWorkspaceLocator::ContainerLayer),
1739 }
1740}
1741
1742pub fn container_workspace_root(recorded: Option<&Path>) -> String {
1750 recorded.map_or_else(
1751 || CONTAINER_WORKSPACE.to_owned(),
1752 |path| path.to_string_lossy().into_owned(),
1753 )
1754}
1755
1756pub fn new_container_workspace(session_id: &str) -> Result<PathBuf> {
1758 validate_session_id(session_id)?;
1759 Ok(Path::new(CONTAINER_WORKSPACE).join(session_id))
1760}
1761
1762pub fn aws_workspace(session_id: &str) -> String {
1768 format!(".local/share/hel/workspaces/{session_id}")
1769}
1770
1771pub fn workspace_for(template: &TargetTemplate, session_id: &str) -> Result<String> {
1772 validate_session_id(session_id)?;
1773 match template {
1774 TargetTemplate::LocalBare => bail!("local bare projects use their selected directory"),
1775 TargetTemplate::LocalPodman(_)
1778 | TargetTemplate::LocalDocker(_)
1779 | TargetTemplate::AppleContainer(_)
1780 | TargetTemplate::SshPodman { .. }
1781 | TargetTemplate::SshDocker { .. } => {
1782 bail!("container targets use the session's recorded container workspace")
1783 }
1784 TargetTemplate::AwsEc2(_) => Ok(aws_workspace(session_id)),
1785 TargetTemplate::SshBare {
1786 workspace_prefix, ..
1787 } => {
1788 validate_workspace_prefix(workspace_prefix)?;
1789 let prefix = workspace_prefix
1794 .strip_prefix("~/")
1795 .unwrap_or(workspace_prefix);
1796 Ok(format!("{}/{session_id}", prefix.trim_end_matches('/')))
1797 }
1798 }
1799}
1800
1801pub fn command_on_locator(
1803 locator: &TargetLocator,
1804 session_id: &str,
1805 args: Vec<String>,
1806 purpose: impl Into<String>,
1807) -> Result<CommandSpec> {
1808 verify_locator(locator, session_id)?;
1809 if args.is_empty() {
1810 bail!("target command must not be empty");
1811 }
1812 Ok(locator_command(locator, args).purpose(purpose))
1813}
1814
1815pub fn locator_command(locator: &TargetLocator, args: Vec<String>) -> CommandSpec {
1819 match locator {
1820 TargetLocator::LocalBare { .. } => {
1821 let mut args = args.into_iter();
1822 let program = args.next().expect("target command must not be empty");
1823 CommandSpec::new(program, args)
1824 }
1825 TargetLocator::LocalPodman { container_id, .. }
1826 | TargetLocator::LocalDocker { container_id, .. }
1827 | TargetLocator::AppleContainer { container_id, .. } => container_exec(
1828 locator.container_engine().expect("local container"),
1829 container_id,
1830 args,
1831 ),
1832 TargetLocator::AwsEc2 { ssh, .. } | TargetLocator::SshBare { ssh, .. } => {
1833 ssh_command_owned(ssh, args)
1834 }
1835 TargetLocator::SshPodman {
1836 ssh, container_id, ..
1837 }
1838 | TargetLocator::SshDocker {
1839 ssh, container_id, ..
1840 } => {
1841 let mut remote = vec![
1842 locator
1843 .container_engine()
1844 .expect("remote container")
1845 .to_owned(),
1846 "exec".to_owned(),
1847 "-i".to_owned(),
1848 container_id.to_owned(),
1849 ];
1850 remote.extend(args);
1851 ssh_command_owned(ssh, remote)
1852 }
1853 }
1854}
1855pub fn worker_root(locator: &TargetLocator, session_id: &str) -> Result<String> {
1856 verify_locator(locator, session_id)?;
1857 Ok(match locator {
1858 TargetLocator::LocalBare { worker_root } => worker_root.clone(),
1859 TargetLocator::LocalPodman { .. }
1860 | TargetLocator::LocalDocker { .. }
1861 | TargetLocator::AppleContainer { .. }
1862 | TargetLocator::SshPodman { .. }
1863 | TargetLocator::SshDocker { .. } => format!("/var/lib/hel/workers/{session_id}"),
1864 TargetLocator::AwsEc2 { .. } => format!(".local/share/hel/workers/{session_id}"),
1865 TargetLocator::SshBare { worker_id, .. } => format!(
1866 ".local/share/hel/workers/{}",
1867 worker_id.as_deref().unwrap_or(session_id)
1868 ),
1869 })
1870}
1871mod convert;
1872pub use convert::{StoredTarget, TargetConversionError, ssh_args_with_identity};
1873
1874mod ssh;
1875pub use ssh::*;
1876
1877pub fn container_exec(
1878 engine: &str,
1879 container_id: &str,
1880 args: impl IntoIterator<Item = impl Into<String>>,
1881) -> CommandSpec {
1882 let mut command_args = vec!["exec".to_owned(), "-i".to_owned(), container_id.to_owned()];
1883 command_args.extend(args.into_iter().map(Into::into));
1884 CommandSpec::new(engine, command_args)
1885}
1886
1887#[cfg(all(test, unix))]
1888mod executor_tests {
1889 use std::fs;
1890
1891 use super::*;
1892
1893 fn flaky_ssh_script(directory: &Path) -> CommandSpec {
1897 let counter = directory.join("attempts");
1898 let script = format!(
1899 "count=$(cat {counter} 2>/dev/null || echo 0)\n\
1900 echo $((count + 1)) > {counter}\n\
1901 if [ \"$count\" -eq 0 ]; then\n\
1902 echo 'kex_exchange_identification: Connection closed by 10.0.0.1 port 22' >&2\n\
1903 exit 255\n\
1904 fi\n\
1905 echo connected\n",
1906 counter = counter.display()
1907 );
1908 CommandSpec::new("sh", ["-c".to_owned(), script])
1909 .ssh_destination("build@10.0.0.1")
1910 .purpose("run the flaky SSH fixture")
1911 }
1912
1913 fn attempts(directory: &Path) -> u32 {
1914 fs::read_to_string(directory.join("attempts"))
1915 .expect("the fixture records its attempts")
1916 .trim()
1917 .parse()
1918 .expect("attempt count is a number")
1919 }
1920
1921 #[test]
1922 fn a_transport_rejected_ssh_command_is_retried_once_and_then_succeeds() {
1923 set_ssh_retry_backoff_for_test(Some(Duration::from_millis(5)));
1924 let directory = tempfile::tempdir().expect("temp dir");
1925 let command = flaky_ssh_script(directory.path());
1926
1927 let output = ProcessExecutor
1928 .execute(&command)
1929 .expect("the retry must reach the successful attempt");
1930
1931 assert_eq!(output.status, 0);
1932 assert_eq!(String::from_utf8_lossy(&output.stdout).trim(), "connected");
1933 assert_eq!(attempts(directory.path()), 2);
1934 set_ssh_retry_backoff_for_test(None);
1935 }
1936
1937 #[test]
1938 fn an_untagged_command_is_not_retried_after_the_same_failure() {
1939 set_ssh_retry_backoff_for_test(Some(Duration::from_millis(5)));
1940 let directory = tempfile::tempdir().expect("temp dir");
1941 let mut command = flaky_ssh_script(directory.path());
1942 command.ssh_destination = None;
1943
1944 let output = ProcessExecutor.execute(&command).expect("runs once");
1945
1946 assert_eq!(output.status, 255);
1947 assert_eq!(attempts(directory.path()), 1);
1948 set_ssh_retry_backoff_for_test(None);
1949 }
1950
1951 #[test]
1952 fn the_cancellable_executor_also_retries_a_transport_rejection() {
1953 set_ssh_retry_backoff_for_test(Some(Duration::from_millis(5)));
1954 let directory = tempfile::tempdir().expect("temp dir");
1955 let command = flaky_ssh_script(directory.path());
1956
1957 let output = CancellableProcessExecutor::new(Arc::new(AtomicBool::new(false)))
1958 .execute(&command)
1959 .expect("the retry must reach the successful attempt");
1960
1961 assert_eq!(output.status, 0);
1962 assert_eq!(attempts(directory.path()), 2);
1963 set_ssh_retry_backoff_for_test(None);
1964 }
1965}