1use std::collections::{BTreeMap, BTreeSet};
8use std::fs;
9use std::io::{Read, Write};
10use std::path::{Path, PathBuf};
11use std::process::{Command, Stdio};
12use std::sync::Arc;
13use std::sync::atomic::{AtomicBool, Ordering};
14use std::time::{Duration, Instant};
15
16use anyhow::{Context, Result, bail, ensure};
17use serde::{Deserialize, Serialize};
18use sha2::{Digest, Sha256};
19
20use crate::config::{HarnessKind, ImagePullPolicy};
21
22pub const SESSION_LABEL: &str = "dev.mj.session";
23pub const MANAGED_LABEL: &str = "dev.mj.managed";
24pub const SESSION_TAG: &str = "dev.mj.session";
25pub const MANAGED_TAG: &str = "dev.mj.managed";
26pub const CONTAINER_WORKSPACE: &str = "/workspace";
27
28#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
30pub enum ProvisionStage {
31 Provisioning,
32 Booting,
33 Cloning,
34 Syncing,
35 Restoring,
36 Starting,
37 Installing(HarnessKind),
38 Compacting,
39 RecoveryCopy,
40 Verifying,
41 Closing,
42 StoppingTarget,
43 RemovingContainer,
44 RemovingStorage,
45 CleaningCache,
46}
47
48impl ProvisionStage {
49 pub fn label(self) -> String {
50 match self {
51 Self::Provisioning => "Provision".into(),
52 Self::Booting => "Boot".into(),
53 Self::Cloning => "Clone".into(),
54 Self::Syncing => "Sync".into(),
55 Self::Restoring => "Restore".into(),
56 Self::Starting => "Start".into(),
57 Self::Installing(harness) => format!("Installing {}", harness.display_name()),
58 Self::Compacting => "Compact".into(),
59 Self::RecoveryCopy => "Recovery copy".into(),
60 Self::Verifying => "Verify".into(),
61 Self::Closing => "Close".into(),
62 Self::StoppingTarget => "Stop target".into(),
63 Self::RemovingContainer => "Remove container".into(),
64 Self::RemovingStorage => "Remove container storage".into(),
65 Self::CleaningCache => "Clean cache".into(),
66 }
67 }
68}
69
70#[derive(Clone, PartialEq, Eq)]
71struct SensitiveCommandInput(Vec<u8>);
72
73impl std::fmt::Debug for SensitiveCommandInput {
74 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
75 formatter.write_str("<redacted>")
76 }
77}
78
79#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
80pub struct CommandSpec {
81 pub program: String,
82 pub args: Vec<String>,
83 #[serde(default)]
84 pub env: BTreeMap<String, String>,
85 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
87 pub clear_env: bool,
88 #[serde(default, skip_serializing_if = "Option::is_none")]
89 pub cwd: Option<std::path::PathBuf>,
90 pub purpose: String,
91 #[serde(default)]
92 pub stage: Option<ProvisionStage>,
93 #[serde(default)]
98 pub parallel_group: Option<u32>,
99 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
103 pub creates_target: bool,
104 #[serde(skip)]
107 sensitive_stdin: Option<SensitiveCommandInput>,
108}
109
110impl CommandSpec {
111 pub fn new(
112 program: impl Into<String>,
113 args: impl IntoIterator<Item = impl Into<String>>,
114 ) -> Self {
115 Self {
116 program: program.into(),
117 args: args.into_iter().map(Into::into).collect(),
118 env: BTreeMap::new(),
119 clear_env: false,
120 cwd: None,
121 purpose: String::new(),
122 stage: None,
123 parallel_group: None,
124 creates_target: false,
125 sensitive_stdin: None,
126 }
127 }
128
129 pub fn purpose(mut self, purpose: impl Into<String>) -> Self {
130 self.purpose = purpose.into();
131 self
132 }
133
134 pub fn stage(mut self, stage: ProvisionStage) -> Self {
135 self.stage = Some(stage);
136 self
137 }
138
139 pub fn parallel_group(mut self, group: u32) -> Self {
142 self.parallel_group = Some(group);
143 self
144 }
145
146 pub fn creates_target(mut self) -> Self {
148 self.creates_target = true;
149 self
150 }
151
152 pub fn with_sensitive_stdin(mut self, input: Vec<u8>) -> Self {
155 self.sensitive_stdin = Some(SensitiveCommandInput(input));
156 self
157 }
158}
159
160#[derive(Debug, Clone, PartialEq, Eq)]
161pub struct CommandOutput {
162 pub status: i32,
163 pub stdout: Vec<u8>,
164 pub stderr: Vec<u8>,
165}
166
167#[derive(Debug, Clone, PartialEq, Eq)]
168pub struct SessionResourceUsage {
169 pub cpu_percent: Option<u8>,
170 pub memory_current_bytes: u64,
171 pub memory_limit_bytes: Option<u64>,
172 pub swap_current_bytes: Option<u64>,
173 pub swap_limit_bytes: Option<u64>,
174 pub writable_disk_bytes: Option<u64>,
175}
176
177#[derive(Debug, Clone, PartialEq, Eq)]
178pub struct SessionResourceProbe {
179 pub memory: CommandSpec,
180 pub disk: Option<CommandSpec>,
181}
182
183#[derive(Debug, Clone, Copy, PartialEq, Eq)]
184pub enum DeploymentCapacityKind {
185 Host,
186 AwsFleet,
187}
188
189#[derive(Debug, Clone, PartialEq, Eq)]
190pub struct DeploymentCapacityTarget {
191 pub id: String,
192 pub host: String,
193 pub target_ids: Vec<String>,
194 pub kind: DeploymentCapacityKind,
195 pub local: bool,
196 pub probes: Vec<CommandSpec>,
198 pub probe_error: Option<String>,
200}
201
202#[derive(Debug, Clone, PartialEq, Eq)]
203pub struct DeploymentCapacityUsage {
204 pub cpu_percent: Option<u8>,
205 pub memory_used_bytes: u64,
206 pub memory_total_bytes: u64,
207 pub logical_cores: u64,
208 pub disk_total_bytes: Option<u64>,
209}
210
211#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
217#[serde(deny_unknown_fields)]
218pub struct AdditionalMount {
219 pub source: PathBuf,
220 pub destination: PathBuf,
221 #[serde(default)]
225 pub read_only: bool,
226}
227
228pub fn overlay_unsupported_filesystem(filesystem: &str) -> Option<&'static str> {
234 let name = filesystem.trim().to_ascii_lowercase();
235 if name == "fuse" || name == "fuseblk" || name.starts_with("fuse.") {
237 return Some("FUSE filesystem");
238 }
239 match name.as_str() {
240 "nfs" | "nfs4" | "cifs" | "smb2" | "smb3" | "9p" | "v9fs" | "virtiofs" | "ceph"
241 | "lustre" | "afs" | "glusterfs" | "ocfs2" | "gfs" | "gfs2" => Some("network filesystem"),
242 "msdos" | "vfat" | "fat" | "exfat" | "ntfs" | "ntfs3" => Some("no POSIX metadata"),
243 "overlayfs" => Some("overlay stacking limit"),
244 _ => None,
245 }
246}
247
248pub fn validate_mount_destination(path: &Path) -> Result<()> {
250 ensure!(
251 path.is_absolute()
252 && !path
253 .components()
254 .any(|part| part == std::path::Component::ParentDir),
255 "additional mount destination must be a safe absolute container path; ~ is not supported"
256 );
257 Ok(())
258}
259
260pub fn validate_additional_mounts(mounts: &[AdditionalMount]) -> Result<()> {
261 let mut destinations = BTreeSet::new();
262 for mount in mounts {
263 if !mount.source.is_absolute() || mount.source.as_os_str().is_empty() {
264 bail!("additional mount source must be an absolute directory path");
265 }
266 validate_mount_destination(&mount.destination)?;
267 if !destinations.insert(mount.destination.clone()) {
268 bail!(
269 "additional mount destination {:?} is configured more than once",
270 mount.destination
271 );
272 }
273 }
274 Ok(())
275}
276
277pub fn default_mount_destination(source: &Path, existing: &[AdditionalMount]) -> PathBuf {
279 let basename = source
280 .file_name()
281 .filter(|name| !name.is_empty())
282 .unwrap_or_else(|| std::ffi::OsStr::new("mount"));
283 let base = PathBuf::from("/mnt").join(basename);
284 if !existing.iter().any(|mount| mount.destination == base) {
285 return base;
286 }
287 for number in 2.. {
288 let candidate =
289 PathBuf::from("/mnt").join(format!("{}-{number}", basename.to_string_lossy()));
290 if !existing.iter().any(|mount| mount.destination == candidate) {
291 return candidate;
292 }
293 }
294 unreachable!("a finite mount list always has an unused numbered destination")
295}
296
297pub fn local_directory_completions(prefix: &str) -> Vec<String> {
299 let (directory, fragment) = match prefix.rsplit_once('/') {
300 Some((directory, fragment)) => (format!("{directory}/"), fragment),
301 None => (String::new(), prefix),
302 };
303 let lookup = if directory.is_empty() {
304 "."
305 } else {
306 &directory
307 };
308 let entries = match fs::read_dir(lookup) {
309 Ok(entries) => entries,
310 Err(error) => {
311 tracing::debug!(path = lookup, %error, "path completion directory could not be read");
312 return Vec::new();
313 }
314 };
315 let mut matches = entries
316 .filter_map(|entry| {
317 let entry = match entry {
318 Ok(entry) => entry,
319 Err(error) => {
320 tracing::debug!(path = lookup, %error, "path completion directory entry could not be read");
321 return None;
322 }
323 };
324 let name = entry.file_name();
325 let name = match name.to_str() {
326 Some(name) => name,
327 None => {
328 tracing::debug!(path = %entry.path().display(), "path completion skipped a non-UTF-8 directory entry");
329 return None;
330 }
331 };
332 (name.starts_with(fragment) && entry.path().is_dir())
333 .then(|| format!("{directory}{name}/"))
334 })
335 .collect::<Vec<_>>();
336 matches.sort();
337 matches.dedup();
338 matches
339}
340
341pub fn path_completion(prefix: &str, candidates: &[String]) -> Option<String> {
343 let first = candidates.first()?;
344 if candidates.len() == 1 {
345 return Some(first.clone());
346 }
347 let common = candidates
348 .iter()
349 .skip(1)
350 .fold(first.clone(), |common, next| {
351 common
352 .chars()
353 .zip(next.chars())
354 .take_while(|(left, right)| left == right)
355 .map(|(character, _)| character)
356 .collect()
357 });
358 (common.len() > prefix.len() && common.starts_with(prefix)).then_some(common)
359}
360
361pub trait CommandExecutor {
362 fn execute(&self, command: &CommandSpec) -> Result<CommandOutput>;
363
364 fn cancellation_requested(&self) -> bool {
368 false
369 }
370
371 fn stage_started(&self, _stage: ProvisionStage) {}
375
376 fn stage_finished(&self, _stage: ProvisionStage) {}
379
380 fn notify_notice(&self, _notice: &str) {}
383
384 fn execute_with_stdin(
385 &self,
386 _command: &CommandSpec,
387 _input: &mut (dyn Read + Send),
388 ) -> Result<CommandOutput> {
389 bail!("this command executor does not support streamed stdin")
390 }
391}
392
393pub struct ProvisionStageGuard<'a, E: CommandExecutor + ?Sized> {
397 executor: &'a E,
398 stage: ProvisionStage,
399}
400
401impl<'a, E: CommandExecutor + ?Sized> ProvisionStageGuard<'a, E> {
402 pub fn new(executor: &'a E, stage: ProvisionStage) -> Self {
403 executor.stage_started(stage);
404 Self { executor, stage }
405 }
406}
407
408impl<E: CommandExecutor + ?Sized> Drop for ProvisionStageGuard<'_, E> {
409 fn drop(&mut self) {
410 self.executor.stage_finished(self.stage);
411 }
412}
413
414pub struct ProcessExecutor;
415
416pub fn trace_command_duration(command: &CommandSpec, started: Instant, status: i32) {
419 tracing::debug!(
420 purpose = command.purpose.as_str(),
421 program = command.program.as_str(),
422 status,
423 elapsed_ms = started.elapsed().as_millis() as u64,
424 "target command finished"
425 );
426}
427
428impl CommandExecutor for ProcessExecutor {
429 fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
430 if let Some(input) = &command.sensitive_stdin {
431 let mut input = std::io::Cursor::new(input.0.as_slice());
432 return self.execute_with_stdin(command, &mut input);
433 }
434 let started = Instant::now();
435 let output = configured_command(command)
436 .stdin(Stdio::null())
437 .output()
438 .with_context(|| format!("run {} for {}", command.program, command.purpose))?;
439 let status = output.status.code().unwrap_or(-1);
440 trace_command_duration(command, started, status);
441 Ok(CommandOutput {
442 status,
443 stdout: output.stdout,
444 stderr: output.stderr,
445 })
446 }
447
448 fn execute_with_stdin(
449 &self,
450 command: &CommandSpec,
451 input: &mut (dyn Read + Send),
452 ) -> Result<CommandOutput> {
453 let process = configured_command(command);
454 stream_command_with_stdin(process, command, input, &|| false)
457 }
458}
459
460fn stream_command_with_stdin(
469 mut process: Command,
470 command: &CommandSpec,
471 input: &mut (dyn Read + Send),
472 is_cancelled: &(dyn Fn() -> bool + Sync),
473) -> Result<CommandOutput> {
474 let started = Instant::now();
475 if is_cancelled() {
476 bail!("operation cancelled");
477 }
478 let mut child = process
479 .stdin(Stdio::piped())
480 .stdout(Stdio::piped())
481 .stderr(Stdio::piped())
482 .spawn()
483 .with_context(|| format!("run {} for {}", command.program, command.purpose))?;
484 let stdin = child
485 .stdin
486 .take()
487 .context("streamed command stdin missing")?;
488 let mut stdout = child
489 .stdout
490 .take()
491 .context("streamed command stdout missing")?;
492 let mut stderr = child
493 .stderr
494 .take()
495 .context("streamed command stderr missing")?;
496 let stdout_reader = std::thread::spawn(move || {
499 let mut bytes = Vec::new();
500 std::io::copy(&mut stdout, &mut bytes).map(|_| bytes)
501 });
502 let stderr_reader = std::thread::spawn(move || {
503 let mut bytes = Vec::new();
504 std::io::copy(&mut stderr, &mut bytes).map(|_| bytes)
505 });
506 let process_result = std::thread::scope(|scope| -> Result<_> {
507 let input_writer = scope.spawn(move || -> Result<()> {
511 let mut stdin = stdin;
516 let mut buffer = [0_u8; 64 * 1024];
517 loop {
518 if is_cancelled() {
522 bail!("operation cancelled");
523 }
524 let count = input.read(&mut buffer).context("read command input")?;
525 if count == 0 {
526 break;
527 }
528 stdin
529 .write_all(&buffer[..count])
530 .context("stream command input")?;
531 }
532 stdin.flush().context("flush command input")
533 });
534 let status = loop {
535 if is_cancelled() {
536 terminate_cancellable_child(&mut child);
537 if let Err(error) = input_writer.join() {
538 tracing::warn!(
539 purpose = command.purpose.as_str(),
540 "streamed command input writer panicked while cancelling: {error:?}"
541 );
542 }
543 bail!("operation cancelled while {}", command.purpose);
544 }
545 match child.try_wait() {
546 Ok(Some(status)) => break status,
547 Ok(None) => std::thread::sleep(Duration::from_millis(25)),
548 Err(error) => {
549 terminate_cancellable_child(&mut child);
550 if let Err(join_error) = input_writer.join() {
551 tracing::warn!(
552 purpose = command.purpose.as_str(),
553 "streamed command input writer panicked while waiting: {join_error:?}"
554 );
555 }
556 return Err(error).with_context(|| format!("wait for {}", command.purpose));
557 }
558 }
559 };
560 let input_result = input_writer
561 .join()
562 .map_err(|_| anyhow::anyhow!("streamed command input writer panicked"))?;
563 Ok((status, input_result))
564 });
565 let stdout = stdout_reader
566 .join()
567 .map_err(|_| anyhow::anyhow!("streamed command stdout reader panicked"))??;
568 let stderr = stderr_reader
569 .join()
570 .map_err(|_| anyhow::anyhow!("streamed command stderr reader panicked"))??;
571 let (status, input_result) = process_result?;
572 if status.success() {
573 input_result?;
577 }
578 let status = status.code().unwrap_or(-1);
579 trace_command_duration(command, started, status);
580 Ok(CommandOutput {
581 status,
582 stdout,
583 stderr,
584 })
585}
586
587#[derive(Clone)]
588pub struct CancellableProcessExecutor {
589 cancelled: Arc<AtomicBool>,
590 deadline: Option<Instant>,
591}
592
593impl CancellableProcessExecutor {
594 pub fn new(cancelled: Arc<AtomicBool>) -> Self {
595 Self {
596 cancelled,
597 deadline: None,
598 }
599 }
600
601 pub fn is_cancelled(&self) -> bool {
602 self.cancelled.load(Ordering::Acquire)
603 || self
604 .deadline
605 .is_some_and(|deadline| Instant::now() >= deadline)
606 }
607
608 pub fn with_timeout(timeout: Duration) -> Self {
609 Self {
610 cancelled: Arc::new(AtomicBool::new(false)),
611 deadline: Some(Instant::now() + timeout),
612 }
613 }
614
615 pub fn with_deadline(mut self, timeout: Duration) -> Self {
618 self.deadline = Some(Instant::now() + timeout);
619 self
620 }
621
622 fn check_cancelled(&self) -> Result<()> {
623 if self.is_cancelled() {
624 bail!("operation cancelled");
625 }
626 Ok(())
627 }
628}
629
630fn configured_command(command: &CommandSpec) -> Command {
631 let mut process = Command::new(&command.program);
632 if command.clear_env {
633 process.env_clear();
634 }
635 if let Some(cwd) = &command.cwd {
636 process.current_dir(cwd);
637 }
638 process.args(&command.args).envs(&command.env);
639 process
640}
641
642fn cancellable_command(command: &CommandSpec) -> Command {
643 #[cfg(unix)]
644 let mut process = configured_command(command);
645 #[cfg(not(unix))]
646 let process = configured_command(command);
647 #[cfg(unix)]
648 {
649 use std::os::unix::process::CommandExt as _;
650 process.process_group(0);
651 }
652 process
653}
654
655fn terminate_cancellable_child(child: &mut std::process::Child) {
656 #[cfg(unix)]
657 if let Err(error) = crate::subprocess::signal_process_group(child.id() as i32, libc::SIGKILL) {
662 tracing::warn!(pid = child.id(), %error, "could not terminate cancelled command process group");
663 }
664 #[cfg(not(unix))]
665 if let Err(error) = child.kill() {
666 tracing::warn!(pid = child.id(), %error, "could not terminate cancelled command");
667 }
668 if let Err(error) = child.wait() {
669 tracing::warn!(pid = child.id(), %error, "could not reap cancelled command");
670 }
671}
672
673impl CommandExecutor for CancellableProcessExecutor {
674 fn cancellation_requested(&self) -> bool {
675 self.is_cancelled()
676 }
677
678 fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
679 if let Some(input) = &command.sensitive_stdin {
680 let mut input = std::io::Cursor::new(input.0.as_slice());
681 return self.execute_with_stdin(command, &mut input);
682 }
683 let started = Instant::now();
684 self.check_cancelled()?;
685 let mut child = cancellable_command(command)
686 .stdin(Stdio::null())
687 .stdout(Stdio::piped())
688 .stderr(Stdio::piped())
689 .spawn()
690 .with_context(|| format!("run {} for {}", command.program, command.purpose))?;
691 let mut stdout = child.stdout.take().context("command stdout missing")?;
692 let mut stderr = child.stderr.take().context("command stderr missing")?;
693 let stdout_reader = std::thread::spawn(move || {
694 let mut bytes = Vec::new();
695 std::io::copy(&mut stdout, &mut bytes).map(|_| bytes)
696 });
697 let stderr_reader = std::thread::spawn(move || {
698 let mut bytes = Vec::new();
699 std::io::copy(&mut stderr, &mut bytes).map(|_| bytes)
700 });
701 let mut status = None;
702 let status = loop {
703 if self.is_cancelled() {
704 terminate_cancellable_child(&mut child);
705 for (stream, reader) in [("stdout", stdout_reader), ("stderr", stderr_reader)] {
706 match reader.join() {
707 Ok(Ok(_)) => {}
708 Ok(Err(error)) => {
709 tracing::warn!(stream, %error, "cancelled command reader failed")
710 }
711 Err(_) => tracing::warn!(stream, "cancelled command reader panicked"),
712 }
713 }
714 bail!("operation cancelled while {}", command.purpose);
715 }
716 if status.is_none() {
717 status = child
718 .try_wait()
719 .with_context(|| format!("wait for {}", command.purpose))?;
720 }
721 if let Some(status) = status
724 && stdout_reader.is_finished()
725 && stderr_reader.is_finished()
726 {
727 break status;
728 }
729 std::thread::sleep(Duration::from_millis(25));
730 };
731 let stdout = stdout_reader
732 .join()
733 .map_err(|_| anyhow::anyhow!("command stdout reader panicked"))??;
734 let stderr = stderr_reader
735 .join()
736 .map_err(|_| anyhow::anyhow!("command stderr reader panicked"))??;
737 let status = status.code().unwrap_or(-1);
738 trace_command_duration(command, started, status);
739 Ok(CommandOutput {
740 status,
741 stdout,
742 stderr,
743 })
744 }
745
746 fn execute_with_stdin(
747 &self,
748 command: &CommandSpec,
749 input: &mut (dyn Read + Send),
750 ) -> Result<CommandOutput> {
751 stream_command_with_stdin(cancellable_command(command), command, input, &|| {
754 self.is_cancelled()
755 })
756 }
757}
758
759#[derive(Debug, Clone, Copy)]
768pub struct BoundedProcessExecutor {
769 timeout: Duration,
770}
771
772impl BoundedProcessExecutor {
773 pub const fn new(timeout: Duration) -> Self {
774 Self { timeout }
775 }
776}
777
778impl CommandExecutor for BoundedProcessExecutor {
779 fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
780 let executor = CancellableProcessExecutor::with_timeout(self.timeout);
781 executor.execute(command).map_err(|error| {
782 if executor.is_cancelled() {
783 anyhow::anyhow!(
784 "`{}` did not answer within {} seconds while trying to {}",
785 command.program,
786 self.timeout.as_secs(),
787 command.purpose
788 )
789 } else {
790 error
791 }
792 })
793 }
794
795 fn execute_with_stdin(
796 &self,
797 command: &CommandSpec,
798 input: &mut (dyn Read + Send),
799 ) -> Result<CommandOutput> {
800 CancellableProcessExecutor::with_timeout(self.timeout).execute_with_stdin(command, input)
801 }
802}
803
804#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
805pub struct CommandPlan {
806 pub description: String,
807 pub commands: Vec<CommandSpec>,
808}
809
810impl CommandPlan {
811 pub fn provide_target_environment_secret(
815 &mut self,
816 target: &TargetTemplate,
817 name: &str,
818 value: &str,
819 ) -> Result<()> {
820 ensure!(
821 !name.is_empty()
822 && name.bytes().enumerate().all(|(index, byte)| byte == b'_'
823 || byte.is_ascii_alphabetic()
824 || (index > 0 && byte.is_ascii_digit())),
825 "invalid secret environment variable name"
826 );
827 ensure!(
828 !value.as_bytes().contains(&b'\n') && !value.as_bytes().contains(&b'\r'),
829 "secret environment value cannot contain a newline"
830 );
831 let command = self
832 .commands
833 .iter_mut()
834 .find(|command| command.creates_target)
835 .context("provisioning plan has no target creation command")?;
836 let read_and_export = format!("IFS= read -r {name} || exit 1; export {name};");
837 match target {
838 TargetTemplate::LocalPodman(_)
839 | TargetTemplate::LocalDocker(_)
840 | TargetTemplate::AppleContainer(_) => {
841 let program = std::mem::replace(&mut command.program, "sh".to_owned());
842 let args = std::mem::take(&mut command.args);
843 command.args = vec![
844 "-c".to_owned(),
845 format!("{read_and_export} exec \"$@\""),
846 "mj-secret-env".to_owned(),
847 program,
848 ];
849 command.args.extend(args);
850 }
851 TargetTemplate::SshPodman { .. } | TargetTemplate::SshDocker { .. } => {
852 let remote = command
853 .args
854 .last_mut()
855 .context("remote container command has no SSH command argument")?;
856 *remote = format!("{read_and_export} exec {remote}");
857 }
858 TargetTemplate::LocalBare
859 | TargetTemplate::AwsEc2(_)
860 | TargetTemplate::SshBare { .. } => {
861 bail!("target does not support inherited container environment")
862 }
863 }
864 let mut input = value.as_bytes().to_vec();
865 input.push(b'\n');
866 command.sensitive_stdin = Some(SensitiveCommandInput(input));
867 Ok(())
868 }
869
870 pub fn execute(&self, executor: &impl CommandExecutor) -> Result<Vec<CommandOutput>> {
871 let mut outputs = Vec::with_capacity(self.commands.len());
872 for command in &self.commands {
873 let output = executor.execute(command)?;
874 if output.status != 0 {
875 bail!(
876 "{} failed with status {}: {}",
877 command.purpose,
878 output.status,
879 String::from_utf8_lossy(&output.stderr)
880 );
881 }
882 outputs.push(output);
883 }
884 Ok(outputs)
885 }
886
887 pub fn execute_concurrent(
899 &self,
900 executor: &(impl CommandExecutor + Sync),
901 ) -> Result<Vec<CommandOutput>> {
902 let mut outputs = Vec::with_capacity(self.commands.len());
903 let mut index = 0;
904 while index < self.commands.len() {
905 let group = self.commands[index].parallel_group;
906 let mut end = index + 1;
907 if group.is_some() {
908 while end < self.commands.len() && self.commands[end].parallel_group == group {
909 end += 1;
910 }
911 }
912 let batch = &self.commands[index..end];
913 if let [command] = batch {
914 outputs.push(checked_command_output(command, executor.execute(command)?)?);
915 } else {
916 let results: Vec<Result<CommandOutput>> = std::thread::scope(|scope| {
917 let handles: Vec<_> = batch
918 .iter()
919 .map(|command| scope.spawn(|| executor.execute(command)))
920 .collect();
921 handles
922 .into_iter()
923 .map(|handle| match handle.join() {
924 Ok(result) => result,
925 Err(panic) => Err(anyhow::anyhow!(
926 "concurrent command thread panicked: {}",
927 command_thread_panic_message(panic.as_ref())
928 )),
929 })
930 .collect()
931 });
932 for (command, result) in batch.iter().zip(results) {
933 outputs.push(checked_command_output(command, result?)?);
934 }
935 }
936 index = end;
937 }
938 Ok(outputs)
939 }
940
941 pub fn split_at_target_creation(&self) -> Option<(Self, Self)> {
949 let created = self
950 .commands
951 .iter()
952 .position(|command| command.creates_target)?;
953 let (creation, remainder) = self.commands.split_at(created + 1);
954 Some((
955 Self {
956 description: self.description.clone(),
957 commands: creation.to_vec(),
958 },
959 Self {
960 description: self.description.clone(),
961 commands: remainder.to_vec(),
962 },
963 ))
964 }
965}
966
967pub fn checked_command_output(
971 command: &CommandSpec,
972 output: CommandOutput,
973) -> Result<CommandOutput> {
974 if output.status != 0 {
975 bail!(
976 "{} failed with status {}: {}",
977 command.purpose,
978 output.status,
979 String::from_utf8_lossy(&output.stderr)
980 );
981 }
982 Ok(output)
983}
984
985pub fn command_thread_panic_message(payload: &(dyn std::any::Any + Send)) -> String {
987 if let Some(message) = payload.downcast_ref::<&str>() {
988 (*message).to_owned()
989 } else if let Some(message) = payload.downcast_ref::<String>() {
990 message.clone()
991 } else {
992 "non-string panic payload".to_owned()
993 }
994}
995
996#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
997pub struct RepositorySpec {
998 pub url: Option<String>,
1000 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1001 pub push_urls: Vec<String>,
1002 pub destination: String,
1003 pub git_ref: Option<String>,
1004 #[serde(default, skip_serializing_if = "Option::is_none")]
1007 pub reference: Option<String>,
1008}
1009
1010#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1011pub struct ProjectBundleSpec {
1012 pub primary: String,
1013 pub repositories: Vec<RepositorySpec>,
1014}
1015
1016impl ProjectBundleSpec {
1017 pub fn validate(&self) -> Result<()> {
1018 validate_relative_path(&self.primary)?;
1019 if self.repositories.is_empty() {
1020 bail!("a project bundle must contain at least one repository");
1021 }
1022 let mut destinations = std::collections::BTreeSet::new();
1023 for repository in &self.repositories {
1024 validate_relative_path(&repository.destination)?;
1025 ensure!(
1026 repository
1027 .url
1028 .as_deref()
1029 .is_some_and(|url| !url.trim().is_empty() && !url.starts_with('-')),
1030 "isolated repositories require a network Git remote; configure a remote or use a raw local session"
1031 );
1032 crate::remote_git::validate_network_url(
1033 repository.url.as_deref().expect("checked above"),
1034 )?;
1035 for push_url in &repository.push_urls {
1036 crate::remote_git::validate_network_url(push_url)?;
1037 }
1038 ensure!(
1039 repository.git_ref.is_none(),
1040 "git_ref is no longer supported; remove it to start from the remote's default branch"
1041 );
1042 if !destinations.insert(&repository.destination) {
1043 bail!(
1044 "duplicate repository destination {}",
1045 repository.destination
1046 );
1047 }
1048 }
1049 if !destinations.contains(&self.primary) {
1050 bail!("primary repository is not present in the bundle");
1051 }
1052 Ok(())
1053 }
1054}
1055
1056#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1057#[serde(tag = "kind", rename_all = "snake_case")]
1058pub enum PodmanWorkspaceStorage {
1059 PodmanVolume,
1060 HostHelper {
1061 root: String,
1062 helper: Vec<String>,
1063 },
1064 #[default]
1065 ContainerLayer,
1066}
1067
1068#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1069pub struct ContainerTemplate {
1070 pub image: String,
1071 #[serde(default)]
1072 pub pull_policy: ImagePullPolicy,
1073 #[serde(default)]
1074 pub extra_run_args: Vec<String>,
1075 #[serde(default)]
1076 pub workspace_storage: PodmanWorkspaceStorage,
1077}
1078
1079impl ImagePullPolicy {
1080 pub fn resolve(self, image: &str) -> Self {
1083 if self != Self::Auto {
1084 return self;
1085 }
1086 if image_is_digest_pinned(image) {
1087 Self::Missing
1088 } else if image_is_remote(image) && image_uses_latest_tag(image) {
1089 Self::Newer
1090 } else {
1091 Self::Missing
1092 }
1093 }
1094
1095 pub fn at_launch(self, image: &str) -> Self {
1100 if self == Self::Auto {
1101 Self::Missing
1102 } else {
1103 self.resolve(image)
1104 }
1105 }
1106
1107 pub fn podman_value(self) -> &'static str {
1109 match self {
1110 Self::Always => "always",
1111 Self::Newer => "newer",
1112 Self::Missing => "missing",
1113 Self::Never => "never",
1114 Self::Auto => unreachable!("auto pull policy must resolve"),
1115 }
1116 }
1117}
1118
1119#[derive(Debug, Clone, PartialEq, Eq)]
1125pub enum ImageHost {
1126 LocalPodman,
1127 LocalDocker,
1128 SshPodman(SshTarget),
1129 SshDocker(SshTarget),
1130}
1131
1132impl ImageHost {
1133 const fn engine(&self) -> &'static str {
1134 match self {
1135 Self::LocalPodman | Self::SshPodman(_) => "podman",
1136 Self::LocalDocker | Self::SshDocker(_) => "docker",
1137 }
1138 }
1139
1140 pub fn label(&self) -> String {
1142 match self {
1143 Self::LocalPodman => "local podman".to_owned(),
1144 Self::LocalDocker => "local docker".to_owned(),
1145 Self::SshPodman(ssh) => format!("podman on {}", ssh.destination),
1146 Self::SshDocker(ssh) => format!("docker on {}", ssh.destination),
1147 }
1148 }
1149
1150 fn command(&self, args: Vec<String>, purpose: String) -> CommandSpec {
1151 match self {
1152 Self::LocalPodman | Self::LocalDocker => {
1153 CommandSpec::new(args[0].clone(), args[1..].iter().cloned())
1154 }
1155 Self::SshPodman(ssh) | Self::SshDocker(ssh) => ssh_command_owned(ssh, args),
1156 }
1157 .purpose(purpose)
1158 }
1159}
1160
1161#[derive(Debug, Clone, PartialEq, Eq)]
1164pub struct ImageRefresh {
1165 pub host: ImageHost,
1166 pub image: String,
1167 pub platform: Option<String>,
1168 pub image_id: CommandSpec,
1171 pub pull: CommandSpec,
1172 pub prune: CommandSpec,
1174}
1175
1176pub fn image_refresh(
1179 host: ImageHost,
1180 image: &str,
1181 platform: Option<&str>,
1182 pull_policy: ImagePullPolicy,
1183) -> Option<ImageRefresh> {
1184 if !matches!(
1185 pull_policy.resolve(image),
1186 ImagePullPolicy::Always | ImagePullPolicy::Newer
1187 ) {
1188 return None;
1189 }
1190 let engine = host.engine();
1191 let image_id = host.command(
1192 vec![
1193 engine.to_owned(),
1194 "image".to_owned(),
1195 "inspect".to_owned(),
1196 "--format".to_owned(),
1197 "{{.Id}}".to_owned(),
1198 image.to_owned(),
1199 ],
1200 format!("read the cached id of container image {image}"),
1201 );
1202 let mut pull_args = vec![engine.to_owned(), "pull".to_owned()];
1203 if let Some(platform) = platform {
1204 pull_args.push(format!("--platform={platform}"));
1205 }
1206 pull_args.push(image.to_owned());
1207 let pull = host.command(pull_args, format!("refresh container image {image}"));
1208 let prune = host.command(
1209 vec![
1210 engine.to_owned(),
1211 "image".to_owned(),
1212 "prune".to_owned(),
1213 "-f".to_owned(),
1214 ],
1215 "remove dangling container images".to_owned(),
1216 );
1217 Some(ImageRefresh {
1218 host,
1219 image: image.to_owned(),
1220 platform: platform.map(str::to_owned),
1221 image_id,
1222 pull,
1223 prune,
1224 })
1225}
1226
1227fn image_is_digest_pinned(image: &str) -> bool {
1228 image
1229 .rsplit_once('@')
1230 .is_some_and(|(_, digest)| !digest.is_empty())
1231}
1232
1233fn image_is_remote(image: &str) -> bool {
1234 !image.starts_with("localhost/") && !image.starts_with("local/")
1235}
1236
1237fn image_uses_latest_tag(image: &str) -> bool {
1238 let name = image.split_once('@').map_or(image, |(name, _)| name);
1239 let final_component = name.rsplit('/').next().unwrap_or(name);
1240 !final_component.contains(':') || final_component.ends_with(":latest")
1241}
1242
1243#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1244pub struct SshTarget {
1245 pub destination: String,
1246 #[serde(default)]
1247 pub ssh_args: Vec<String>,
1248}
1249
1250#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1251pub struct AwsTemplate {
1252 pub profile: String,
1253 pub region: String,
1254 pub launch_template: String,
1255 pub launch_template_version: Option<String>,
1256 pub instance_type: Option<String>,
1257 pub ssh: SshTarget,
1258}
1259
1260#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1261#[serde(tag = "kind", rename_all = "snake_case")]
1262pub enum TargetTemplate {
1263 LocalBare,
1264 LocalPodman(ContainerTemplate),
1265 LocalDocker(ContainerTemplate),
1266 AppleContainer(ContainerTemplate),
1267 AwsEc2(AwsTemplate),
1268 SshBare {
1269 ssh: SshTarget,
1270 #[serde(default = "default_ssh_prefix")]
1271 workspace_prefix: String,
1272 },
1273 SshPodman {
1274 ssh: SshTarget,
1275 container: ContainerTemplate,
1276 },
1277 SshDocker {
1278 ssh: SshTarget,
1279 container: ContainerTemplate,
1280 },
1281}
1282
1283fn default_ssh_prefix() -> String {
1284 ".local/share/hel/workspaces".to_owned()
1285}
1286
1287#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1288#[serde(tag = "kind", rename_all = "snake_case")]
1289pub enum PodmanWorkspaceLocator {
1290 #[default]
1291 ContainerLayer,
1292 Volume {
1293 name: String,
1294 },
1295 HostPath {
1296 path: String,
1297 helper: Vec<String>,
1298 resource: String,
1299 },
1300}
1301
1302#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1303#[serde(tag = "kind", rename_all = "snake_case")]
1304pub enum TargetLocator {
1305 LocalBare {
1306 worker_root: String,
1307 },
1308 LocalPodman {
1309 container_id: String,
1310 #[serde(default)]
1311 workspace_storage: PodmanWorkspaceLocator,
1312 },
1313 LocalDocker {
1314 container_id: String,
1315 },
1316 AppleContainer {
1317 container_id: String,
1318 },
1319 AwsEc2 {
1320 profile: String,
1321 region: String,
1322 instance_id: String,
1323 ssh: SshTarget,
1324 workspace: String,
1325 },
1326 SshBare {
1327 ssh: SshTarget,
1328 workspace: String,
1329 #[serde(default, skip_serializing_if = "Option::is_none")]
1331 worker_id: Option<String>,
1332 },
1333 SshPodman {
1334 ssh: SshTarget,
1335 container_id: String,
1336 #[serde(default)]
1337 workspace_storage: PodmanWorkspaceLocator,
1338 },
1339 SshDocker {
1340 ssh: SshTarget,
1341 container_id: String,
1342 },
1343}
1344
1345impl TargetTemplate {
1346 pub const fn container_engine(&self) -> Option<&'static str> {
1347 match self {
1348 Self::LocalPodman(_) | Self::SshPodman { .. } => Some("podman"),
1349 Self::LocalDocker(_) | Self::SshDocker { .. } => Some("docker"),
1350 Self::AppleContainer(_) => Some("container"),
1351 _ => None,
1352 }
1353 }
1354}
1355
1356impl TargetLocator {
1357 pub const fn container_engine(&self) -> Option<&'static str> {
1358 match self {
1359 Self::LocalPodman { .. } | Self::SshPodman { .. } => Some("podman"),
1360 Self::LocalDocker { .. } | Self::SshDocker { .. } => Some("docker"),
1361 Self::AppleContainer { .. } => Some("container"),
1362 _ => None,
1363 }
1364 }
1365}
1366
1367#[derive(Debug, Clone, PartialEq, Eq)]
1371pub struct TargetRecoveryPlan {
1372 pub exists: CommandSpec,
1373 pub inspect: CommandSpec,
1374 pub start: CommandSpec,
1375 pub session_id: String,
1376}
1377
1378#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1379pub enum TargetRecoveryOutcome {
1380 NotRequired,
1381 Missing,
1382 AlreadyRunning,
1383 Started,
1384}
1385
1386pub fn resource_name(session_id: &str) -> Result<String> {
1387 validate_session_id(session_id)?;
1388 let readable: String = session_id
1389 .chars()
1390 .filter(|character| character.is_ascii_alphanumeric())
1391 .take(12)
1392 .map(|character| character.to_ascii_lowercase())
1393 .collect();
1394 let digest = Sha256::digest(session_id.as_bytes());
1395 Ok(format!(
1396 "mj-{readable}-{:02x}{:02x}{:02x}",
1397 digest[0], digest[1], digest[2]
1398 ))
1399}
1400
1401pub fn podman_workspace_locator(
1402 template: &ContainerTemplate,
1403 session_id: &str,
1404) -> Result<PodmanWorkspaceLocator> {
1405 let resource = format!("{}-workspace", resource_name(session_id)?);
1406 match &template.workspace_storage {
1407 PodmanWorkspaceStorage::PodmanVolume => {
1408 Ok(PodmanWorkspaceLocator::Volume { name: resource })
1409 }
1410 PodmanWorkspaceStorage::HostHelper { root, helper } => {
1411 let root = Path::new(root);
1412 ensure!(
1413 root.is_absolute(),
1414 "Podman workspace storage root must be absolute"
1415 );
1416 ensure!(
1417 !helper.is_empty() && helper.iter().all(|argument| !argument.is_empty()),
1418 "Podman workspace storage helper must contain non-empty arguments"
1419 );
1420 Ok(PodmanWorkspaceLocator::HostPath {
1421 path: root.join(&resource).to_string_lossy().into_owned(),
1422 helper: helper.clone(),
1423 resource,
1424 })
1425 }
1426 PodmanWorkspaceStorage::ContainerLayer => Ok(PodmanWorkspaceLocator::ContainerLayer),
1427 }
1428}
1429
1430pub fn workspace_for(template: &TargetTemplate, session_id: &str) -> Result<String> {
1431 validate_session_id(session_id)?;
1432 match template {
1433 TargetTemplate::LocalBare => bail!("local bare projects use their selected directory"),
1434 TargetTemplate::LocalPodman(_)
1435 | TargetTemplate::LocalDocker(_)
1436 | TargetTemplate::AppleContainer(_)
1437 | TargetTemplate::SshPodman { .. }
1438 | TargetTemplate::SshDocker { .. } => Ok(CONTAINER_WORKSPACE.to_owned()),
1439 TargetTemplate::AwsEc2(_) => Ok(format!(".local/share/hel/workspaces/{session_id}")),
1440 TargetTemplate::SshBare {
1441 workspace_prefix, ..
1442 } => {
1443 validate_workspace_prefix(workspace_prefix)?;
1444 let prefix = workspace_prefix
1449 .strip_prefix("~/")
1450 .unwrap_or(workspace_prefix);
1451 Ok(format!("{}/{session_id}", prefix.trim_end_matches('/')))
1452 }
1453 }
1454}
1455
1456pub fn command_on_locator(
1458 locator: &TargetLocator,
1459 session_id: &str,
1460 args: Vec<String>,
1461 purpose: impl Into<String>,
1462) -> Result<CommandSpec> {
1463 verify_locator(locator, session_id)?;
1464 if args.is_empty() {
1465 bail!("target command must not be empty");
1466 }
1467 let command = match locator {
1468 TargetLocator::LocalBare { .. } => {
1469 let mut args = args.into_iter();
1470 let program = args.next().expect("checked non-empty target command");
1471 CommandSpec::new(program, args)
1472 }
1473 TargetLocator::LocalPodman { container_id, .. } => {
1474 container_exec("podman", container_id, args)
1475 }
1476 TargetLocator::LocalDocker { container_id } => container_exec("docker", container_id, args),
1477 TargetLocator::AppleContainer { container_id } => {
1478 container_exec("container", container_id, args)
1479 }
1480 TargetLocator::AwsEc2 { ssh, .. } | TargetLocator::SshBare { ssh, .. } => {
1481 ssh_command_owned(ssh, args)
1482 }
1483 TargetLocator::SshPodman {
1484 ssh, container_id, ..
1485 }
1486 | TargetLocator::SshDocker { ssh, container_id } => {
1487 let mut remote = vec![
1488 locator
1489 .container_engine()
1490 .expect("remote container")
1491 .to_owned(),
1492 "exec".to_owned(),
1493 "-i".to_owned(),
1494 container_id.to_owned(),
1495 ];
1496 remote.extend(args);
1497 ssh_command_owned(ssh, remote)
1498 }
1499 };
1500 Ok(command.purpose(purpose))
1501}
1502pub fn worker_root(locator: &TargetLocator, session_id: &str) -> Result<String> {
1503 verify_locator(locator, session_id)?;
1504 Ok(match locator {
1505 TargetLocator::LocalBare { worker_root } => worker_root.clone(),
1506 TargetLocator::LocalPodman { .. }
1507 | TargetLocator::LocalDocker { .. }
1508 | TargetLocator::AppleContainer { .. }
1509 | TargetLocator::SshPodman { .. }
1510 | TargetLocator::SshDocker { .. } => format!("/var/lib/hel/workers/{session_id}"),
1511 TargetLocator::AwsEc2 { .. } => format!(".local/share/hel/workers/{session_id}"),
1512 TargetLocator::SshBare { worker_id, .. } => format!(
1513 ".local/share/hel/workers/{}",
1514 worker_id.as_deref().unwrap_or(session_id)
1515 ),
1516 })
1517}
1518mod ssh;
1519pub use ssh::*;
1520
1521pub fn container_exec(
1522 engine: &str,
1523 container_id: &str,
1524 args: impl IntoIterator<Item = impl Into<String>>,
1525) -> CommandSpec {
1526 let mut command_args = vec!["exec".to_owned(), "-i".to_owned(), container_id.to_owned()];
1527 command_args.extend(args.into_iter().map(Into::into));
1528 CommandSpec::new(engine, command_args)
1529}