1use std::collections::{BTreeMap, HashMap};
4use std::path::{Path, PathBuf};
5use std::process::{Command, Stdio};
6use std::sync::{Arc, Mutex, OnceLock};
7use std::time::{Duration, Instant};
8
9use anyhow::{Context, Result, bail, ensure};
10
11use mj_core::config::{TargetTemplate, atomic_write, data_dir};
12use mj_core::state::{SessionState, State, TargetLocator};
13
14use crate::targets::{
15 self, CancellableProcessExecutor, CommandExecutor, CommandOutput, CommandSpec, ProvisionStage,
16 ProvisionStageGuard,
17};
18
19use super::backend::{
20 ContainerOverrides, backend_bundle, backend_locator, backend_target,
21 configure_github_token_environment, controller_github_token, locator_after_provision,
22 preflight_target, use_github_https_urls,
23};
24use super::git_cache;
25use super::readiness::{connect_started_worker, wait_for_native_session_in_stage};
26use super::worker_binary::{bridge_readiness_stage, start_worker, worker_probe_diagnosis};
27use super::{Controller, execute_checked, now};
28
29const INHERITED_GIT_SETTINGS: &[&str] = &[
30 "diff.algorithm",
31 "fetch.prune",
32 "fetch.prunetags",
33 "init.defaultbranch",
34 "merge.conflictstyle",
35 "pull.ff",
36 "pull.rebase",
37 "push.autosetupremote",
38 "push.default",
39 "rebase.autostash",
40 "rerere.autoupdate",
41 "rerere.enabled",
42 "user.email",
43 "user.name",
44];
45
46const CONTAINER_START_ADMISSION: usize = 2;
57
58fn container_start_gate(locator: &targets::TargetLocator) -> Option<Arc<tokio::sync::Semaphore>> {
64 static GATES: OnceLock<Mutex<HashMap<String, Arc<tokio::sync::Semaphore>>>> = OnceLock::new();
65 let container = match locator {
66 targets::TargetLocator::LocalPodman { container_id, .. }
67 | targets::TargetLocator::LocalDocker { container_id, .. }
68 | targets::TargetLocator::AppleContainer { container_id, .. }
69 | targets::TargetLocator::SshPodman { container_id, .. }
70 | targets::TargetLocator::SshDocker { container_id, .. } => container_id.clone(),
71 targets::TargetLocator::LocalBare { .. }
72 | targets::TargetLocator::SshBare { .. }
73 | targets::TargetLocator::AwsEc2 { .. } => return None,
74 };
75 let gates = GATES.get_or_init(|| Mutex::new(HashMap::new()));
76 let mut gates = gates
77 .lock()
78 .unwrap_or_else(std::sync::PoisonError::into_inner);
79 Some(Arc::clone(gates.entry(container).or_insert_with(|| {
80 Arc::new(tokio::sync::Semaphore::new(CONTAINER_START_ADMISSION))
81 })))
82}
83
84fn subagent_start_is_retryable(error: &anyhow::Error) -> bool {
92 if mj_core::refusal::Refusal::of(error).is_some() {
93 return false;
94 }
95 if format!("{error:#}").contains("operation cancelled") {
96 return false;
97 }
98 error
99 .downcast_ref::<super::readiness::WorkerStartupFailure>()
100 .is_some_and(|failure| !failure.reached_socket)
101}
102
103#[derive(Debug, Clone, Copy, PartialEq, Eq)]
104pub(super) enum ProvisioningFailureDisposition {
105 Discard,
107 Preserve,
109}
110
111impl Controller {
112 pub async fn provision_session_controlled_with_commit(
113 &mut self,
114 session_id: &str,
115 executor: &(impl CommandExecutor + Sync),
116 grant_commit: impl FnOnce() -> Result<()>,
117 ) -> Result<()> {
118 let github_token = controller_github_token();
119 let repositories = self
120 .provision_session_target_with_failure_disposition(
121 session_id,
122 executor,
123 github_token.as_deref(),
124 ProvisioningFailureDisposition::Discard,
125 )
126 .await?;
127 let setup = execute_concurrent_lanes(
128 || execute_repository_setup(&repositories, executor),
129 || self.install_worker_payload(session_id, executor),
130 );
131 let result = match setup {
132 Ok(((), (backend, worker_root))) => {
133 self.connect_and_start_worker(session_id, executor, &backend, &worker_root, true)
134 .await
135 }
136 Err(error) => Err(error),
137 };
138 match result {
139 Ok(native_session_id) => {
140 if let Err(error) = grant_commit() {
141 return Err(self.rollback_failed_new_session(session_id, error, executor)?);
142 }
143 self.mark_worker_connected(session_id, native_session_id)
144 }
145 Err(error) => Err(self.rollback_failed_new_session(session_id, error, executor)?),
146 }
147 }
148
149 pub async fn provision_subagent_session_controlled(
152 &mut self,
153 session_id: &str,
154 executor: &(impl CommandExecutor + Sync),
155 ) -> Result<()> {
156 let mut attempts: Vec<String> = Vec::new();
163 let (result, placement) = loop {
164 let attempt = self.attempt_subagent_start(session_id, executor).await;
165 let (result, placement) = attempt;
166 let Err(error) = &result else {
167 break (result, placement);
168 };
169 if attempts.len() == 1 || !subagent_start_is_retryable(error) {
170 if !attempts.is_empty() {
171 let combined = attempts
172 .iter()
173 .enumerate()
174 .map(|(index, attempt)| format!("attempt {}: {attempt}", index + 1))
175 .chain(std::iter::once(format!(
176 "attempt {}: {error:#}",
177 attempts.len() + 1
178 )))
179 .collect::<Vec<_>>()
180 .join("; ");
181 break (Err(anyhow::anyhow!("{combined}")), placement);
182 }
183 break (result, placement);
184 }
185 tracing::warn!(
186 session_id,
187 error = format!("{error:#}"),
188 "sub-agent worker never started; retrying once"
189 );
190 attempts.push(format!("{error:#}"));
191 if let Some((backend, worker_root)) = &placement
194 && let Err(stop_error) =
195 super::worker_binary::stop_worker(executor, backend, worker_root)
196 {
197 tracing::debug!(
198 session_id,
199 error = format!("{stop_error:#}"),
200 "could not stop the worker of a retried sub-agent start"
201 );
202 }
203 };
204 match result {
205 Ok(native_session_id) => self.mark_worker_connected(session_id, native_session_id),
206 Err(error) => {
207 if let Some((backend, worker_root)) = placement
209 && let Err(stop_error) =
210 super::worker_binary::stop_worker(executor, &backend, &worker_root)
211 {
212 tracing::warn!(
213 session_id,
214 error = format!("{stop_error:#}"),
215 "failed sub-agent worker could not be stopped cleanly"
216 );
217 }
218 tracing::warn!(
219 session_id,
220 error = format!("{error:#}"),
221 "sub-agent startup failed"
222 );
223 let record = self
224 .state
225 .sessions
226 .get_mut(session_id)
227 .context("failed sub-agent session disappeared")?;
228 record.state = SessionState::Error;
229 record.updated_at = super::now();
230 record.last_error = Some(format!("sub-agent startup failed: {error:#}"));
231 crate::database::save_lifecycle_session(record)?;
232 Err(error)
233 }
234 }
235 }
236
237 async fn attempt_subagent_start(
241 &mut self,
242 session_id: &str,
243 executor: &(impl CommandExecutor + Sync),
244 ) -> (
245 Result<Option<String>>,
246 Option<(targets::TargetLocator, String)>,
247 ) {
248 match self.worker_placement(session_id) {
251 Ok((backend, worker_root)) => {
252 let syncing = &StagedExecutor::new(executor, ProvisionStage::Syncing);
253 let prepared =
254 self.prepare_worker_files(session_id, &backend, &worker_root, syncing);
255 let result = match prepared {
256 Ok(()) => {
257 let gate = container_start_gate(&backend);
260 let _admitted = match &gate {
261 Some(gate) => gate.acquire().await.ok(),
262 None => None,
263 };
264 self.connect_and_start_worker(
265 session_id,
266 executor,
267 &backend,
268 &worker_root,
269 false,
270 )
271 .await
272 }
273 Err(error) => Err(error),
274 };
275 (result, Some((backend, worker_root)))
276 }
277 Err(error) => (Err(error), None),
278 }
279 }
280
281 fn rollback_failed_new_session(
282 &mut self,
283 session_id: &str,
284 error: anyhow::Error,
285 executor: &impl CommandExecutor,
286 ) -> Result<anyhow::Error> {
287 let session = self
288 .state
289 .sessions
290 .get(session_id)
291 .with_context(|| format!("unknown session {session_id}"))?
292 .clone();
293 let target_cleanup = match session.target.as_ref() {
294 Some(locator) => (|| -> Result<()> {
295 let backend = backend_locator(locator, &session, &self.config)?;
296 targets::close_plan(&backend, session_id)?
297 .execute(&CancellableProcessExecutor::with_timeout(
300 Duration::from_secs(15),
301 ))
302 .map(|_| ())
303 })(),
304 None => Ok(()),
305 };
306 let worktree_cleanup =
307 self.cleanup_new_session_worktree_after_failure(session_id, executor);
308 let cleanup_error = [target_cleanup, worktree_cleanup]
309 .into_iter()
310 .filter_map(Result::err)
311 .map(|error| format!("{error:#}"))
312 .collect::<Vec<_>>()
313 .join("; ");
314 if !cleanup_error.is_empty() {
315 tracing::warn!(
316 session_id,
317 error = %cleanup_error,
318 "new-session rollback cleanup reported failures"
319 );
320 }
321 let original = note_new_session_launch_failure(session_id, &error);
322 let failure = apply_failed_new_session_rollback(
323 &mut self.state,
324 session_id,
325 &original,
326 (!cleanup_error.is_empty()).then_some(cleanup_error),
327 );
328 self.persist_session_state(session_id)?;
329 Ok(failure)
330 }
331
332 pub(super) async fn provision_session_with_failure_disposition(
333 &mut self,
334 session_id: &str,
335 executor: &(impl CommandExecutor + Sync),
336 github_token: Option<&str>,
337 failure_disposition: ProvisioningFailureDisposition,
338 ) -> Result<()> {
339 let repositories = self
340 .provision_session_target_with_failure_disposition(
341 session_id,
342 executor,
343 github_token,
344 failure_disposition,
345 )
346 .await?;
347 match execute_repository_setup(&repositories, executor) {
348 Ok(()) => Ok(()),
349 Err(error) if failure_disposition == ProvisioningFailureDisposition::Discard => {
350 Err(self.rollback_failed_new_session(session_id, error, executor)?)
351 }
352 Err(error) => Err(error),
353 }
354 }
355
356 async fn provision_session_target_with_failure_disposition(
357 &mut self,
358 session_id: &str,
359 executor: &(impl CommandExecutor + Sync),
360 github_token: Option<&str>,
361 failure_disposition: ProvisioningFailureDisposition,
362 ) -> Result<targets::CommandPlan> {
363 let session = self
364 .state
365 .sessions
366 .get(session_id)
367 .with_context(|| format!("unknown session {session_id}"))?
368 .clone();
369 if session.state != SessionState::Provisioning {
370 bail!("session {session_id} is not provisioning");
371 }
372 let preparation = (|| {
373 let template = self
374 .config
375 .targets
376 .get(&session.target_template_id)
377 .context("target template disappeared during provisioning")?;
378 let profile = self
379 .config
380 .profiles
381 .get(&session.last_profile)
382 .context("harness profile disappeared during provisioning")?;
383 super::worker_binary::preflight_harness(template, profile, executor)?;
384 self.prepare_managed_raw_worktree(session_id, executor)
385 })();
386 let created_worktree = match preparation {
387 Ok(created) => created,
388 Err(error) if failure_disposition == ProvisioningFailureDisposition::Discard => {
389 return Err(self.fail_new_session_with_cleanup(session_id, error, executor)?);
390 }
391 Err(error) => return Err(error),
392 };
393 let session = self
394 .state
395 .sessions
396 .get(session_id)
397 .expect("session retained after managed worktree preparation")
398 .clone();
399 let result = (|| {
402 let template = self
403 .config
404 .targets
405 .get(&session.target_template_id)
406 .context("target template disappeared during provisioning")?;
407 if matches!(template, TargetTemplate::AwsEc2 { .. }) {
408 for resource in &session.additional_mounts {
409 ensure!(
410 resource.source.is_dir(),
411 "attached resource source is not a directory: {}",
412 resource.source.display()
413 );
414 }
415 }
416 let mut target = backend_target(
417 template,
418 session.resource_allocation.as_ref(),
419 ContainerOverrides::for_session(&session),
420 )?;
421 let mut runtime_mounts = if matches!(target, targets::TargetTemplate::AwsEc2(_)) {
422 Vec::new()
423 } else {
424 session.additional_mounts.clone()
425 };
426 for notice in enforce_overlay_capable_mounts(&target, &mut runtime_mounts, executor) {
431 executor.notify_notice(¬ice);
432 }
433 let image_user = podman_image_user(&target, executor);
437 let mut bundle = if session.project_directory.is_some() {
438 None
439 } else if failure_disposition == ProvisioningFailureDisposition::Preserve {
440 Some(super::network_git::checkpoint_bundle(&session)?)
441 } else {
442 Some(backend_bundle(
443 self.config
444 .bundles
445 .get(&session.bundle_id)
446 .context("session bundle is missing")?,
447 executor,
448 )?)
449 };
450 let container_github_token =
451 github_token.filter(|_| configure_github_token_environment(&mut target));
452 if container_github_token.is_some()
453 && let Some(bundle) = bundle.as_mut()
454 {
455 use_github_https_urls(bundle);
456 }
457 preflight_target(template, executor)?;
458 let prepared_cache = bundle.as_mut().and_then(|bundle| {
459 git_cache::prepare(
460 &target,
461 session_id,
462 bundle,
463 &mut runtime_mounts,
464 container_github_token,
465 executor,
466 )
467 });
468 let build_cache = super::mbx::prepare(
471 &target,
472 &self.config.build_cache,
473 &session,
474 bundle.as_ref(),
475 prepared_cache.as_ref(),
476 &mut runtime_mounts,
477 executor,
478 );
479 let provision = if let Some(project_directory) = &session.project_directory {
480 targets::provision_bare_project_plan(
481 &target,
482 session_id,
483 &project_directory.to_string_lossy(),
484 )
485 } else {
486 bundle
487 .as_ref()
488 .context("project bundle disappeared during provisioning")
489 .and_then(|bundle| {
490 targets::provision_plan(
491 &target,
492 session_id,
493 bundle,
494 &runtime_mounts,
495 image_user,
496 session.container_workspace.as_deref(),
497 )
498 })
499 };
500 let mut provision = match provision {
501 Ok(provision) => provision,
502 Err(error) => {
503 if let Some(cache) = &prepared_cache {
504 let _ = cache.cleanup(executor);
505 }
506 return Err(error);
507 }
508 };
509 if let Some(token) = container_github_token
510 && let Err(error) =
511 provision.provide_target_environment_secret(&target, "GH_TOKEN", token)
512 {
513 if let Some(cache) = &prepared_cache {
514 let _ = cache.cleanup(executor);
515 }
516 return Err(error);
517 }
518
519 let started = Instant::now();
520 let result =
521 provision_target_creation(&provision, &target, session_id, executor, |outputs| {
522 locator_after_provision(
523 template,
524 &target,
525 session_id,
526 outputs.first(),
527 executor,
528 )
529 })
530 .map(|(locator, remainder)| (locator, remainder, bundle, build_cache));
531 if result.is_err()
532 && let Some(cache) = &prepared_cache
533 {
534 if let Some(locator) = provisioned_locator(&target, session_id, None) {
535 let _ = targets::close_plan(&locator, session_id)
536 .and_then(|plan| plan.execute(executor).map(|_| ()));
537 } else {
538 let _ = cache.cleanup(executor);
539 }
540 }
541 tracing::debug!(
542 session_id,
543 elapsed_ms = started.elapsed().as_millis(),
544 "provisioning plan execution completed"
545 );
546 result
547 })();
548 let result = match result {
549 Err(error)
550 if created_worktree
551 && failure_disposition == ProvisioningFailureDisposition::Discard =>
552 {
553 return Err(self.fail_new_session_with_cleanup(session_id, error, executor)?);
554 }
555 Err(error) if failure_disposition == ProvisioningFailureDisposition::Preserve => {
556 Err(error)
557 }
558 Err(error) => {
559 let detail = note_new_session_launch_failure(session_id, &error);
563 {
564 let record = self.state.sessions.get_mut(session_id).unwrap();
565 record.state = SessionState::Error;
566 record.target = None;
567 record.updated_at = super::now();
568 record.last_error = Some(format!("session provisioning failed: {detail}"));
569 }
570 return match self.persist_session_state(session_id) {
571 Ok(()) => Err(error),
572 Err(persistence_error) => Err(error.context(format!(
573 "persist removal of failed provisioning session {session_id}: {persistence_error:#}"
574 ))),
575 };
576 }
577 Ok((locator, remainder, bundle, build_cache)) => {
578 apply_new_session_provisioning_result(&mut self.state, session_id, Ok(locator))?;
579 self.state
580 .sessions
581 .get_mut(session_id)
582 .expect("session retained after provisioning")
583 .build_cache = build_cache;
584 let session = &self.state.sessions[session_id];
585 let backend = backend_locator(
586 session
587 .target
588 .as_ref()
589 .context("provisioned target disappeared")?,
590 session,
591 &self.config,
592 )?;
593 if matches!(backend, targets::TargetLocator::AwsEc2 { .. }) {
594 targets::provision_on_locator_plan(
595 &backend,
596 session_id,
597 bundle
598 .as_ref()
599 .context("AWS provisioning requires a project bundle")?,
600 )
601 } else {
602 Ok(remainder)
603 }
604 }
605 };
606 let result = match result {
607 Err(error) if failure_disposition == ProvisioningFailureDisposition::Discard => {
608 return Err(self.rollback_failed_new_session(session_id, error, executor)?);
609 }
610 result => result,
611 };
612 if result.is_ok()
613 && let Some(session) = self.state.sessions.get(session_id)
614 && let Some(directory) = session
615 .managed_worktree
616 .as_ref()
617 .map(|worktree| worktree.source_project_directory.clone())
618 .or_else(|| session.project_directory.clone())
619 && let Some(template) = self.config.targets.get(&session.target_template_id)
620 {
621 let host = match template {
622 TargetTemplate::LocalBare => Some("local"),
623 TargetTemplate::SshBare { ssh, .. } => Some(ssh.host.as_str()),
624 _ => None,
625 };
626 if let Some(host) = host {
627 self.state.remember_project_directory(host, &directory);
628 crate::database::remember_project_directory(host, &directory)?;
629 }
630 }
631 self.persist_session_state(session_id)?;
632 result
633 }
634
635 pub fn mark_worker_connected(
636 &mut self,
637 session_id: &str,
638 native_session_id: Option<String>,
639 ) -> Result<()> {
640 let session = self
641 .state
642 .sessions
643 .get(session_id)
644 .with_context(|| format!("unknown session {session_id}"))?;
645 if session.target.is_none() {
646 bail!("session {session_id} has no provisioned target");
647 }
648 let updated_at = now();
649 crate::database::mark_session_worker_connected(
650 session_id,
651 native_session_id.as_deref(),
652 &updated_at,
653 )?;
654 let session = self
655 .state
656 .sessions
657 .get_mut(session_id)
658 .expect("session disappeared after its worker connection was saved");
659 session.state = SessionState::Running;
660 if native_session_id.is_some() {
661 session.native_session_id = native_session_id;
662 }
663 session.updated_at = updated_at;
664 session.last_error = None;
665 Ok(())
666 }
667
668 fn install_worker_payload(
669 &self,
670 session_id: &str,
671 executor: &impl CommandExecutor,
672 ) -> Result<(targets::TargetLocator, String)> {
673 let syncing = &StagedExecutor::new(executor, ProvisionStage::Syncing);
675 let (backend, worker_root) = self.worker_placement(session_id)?;
676 self.prepare_worker_files(session_id, &backend, &worker_root, syncing)?;
677 install_attached_resources(&self.state, session_id, &backend, &worker_root, syncing)?;
678 Ok((backend, worker_root))
679 }
680
681 async fn connect_and_start_worker(
682 &self,
683 session_id: &str,
684 executor: &impl CommandExecutor,
685 backend: &targets::TargetLocator,
686 worker_root: &str,
687 initialize_workspace: bool,
688 ) -> Result<Option<String>> {
689 let syncing = &StagedExecutor::new(executor, ProvisionStage::Syncing);
690 if initialize_workspace {
691 install_inherited_git_settings(executor, backend, session_id)?;
692 self.initialize_network_workspaces(session_id, backend, syncing)?;
693 }
694 let session = self
695 .state
696 .sessions
697 .get(session_id)
698 .with_context(|| format!("unknown session {session_id}"))?;
699 let profile = self
700 .config
701 .profiles
702 .get(&session.last_profile)
703 .with_context(|| format!("unknown profile {}", session.last_profile))?;
704 let readiness_stage = bridge_readiness_stage(profile);
705 let reconnect = &targets::reconnect_plan(backend, session_id)?.commands[0];
706 let readiness = async {
707 let mut relay = {
708 let _starting = ProvisionStageGuard::new(executor, ProvisionStage::Starting);
709 start_worker(executor, backend, worker_root)?;
710 connect_started_worker(reconnect, session_id, executor, backend, worker_root)
711 .await?
712 };
713 let native_session_id =
714 wait_for_native_session_in_stage(&mut relay, executor, readiness_stage).await?;
715 Ok(Some(native_session_id))
716 }
717 .await;
718 match readiness {
719 Ok(native_session_id) => Ok(native_session_id),
720 Err(error) => Err(worker_probe_diagnosis(
721 executor,
722 backend,
723 worker_root,
724 error,
725 )),
726 }
727 }
728}
729
730const MAX_LAUNCH_DIAGNOSTIC_BYTES: usize = 64 * 1024;
731
732const RETAINED_LAUNCH_DIAGNOSTICS: usize = 20;
733
734pub(super) fn note_new_session_launch_failure(session_id: &str, error: &anyhow::Error) -> String {
740 note_new_session_launch_failure_in(&data_dir().join("diagnostics"), session_id, error)
741}
742
743fn note_new_session_launch_failure_in(
744 directory: &Path,
745 session_id: &str,
746 error: &anyhow::Error,
747) -> String {
748 let original = format!("{error:#}");
749 tracing::warn!(session_id, error = %original, "session launch failed");
750 match persist_launch_failure_to(directory, session_id, &original) {
751 Ok(path) => format!("{original}; full diagnostic saved to {}", path.display()),
752 Err(save_error) => {
753 format!("{original}; saving the local diagnostic failed: {save_error:#}")
754 }
755 }
756}
757
758fn persist_launch_failure_to(directory: &Path, session_id: &str, detail: &str) -> Result<PathBuf> {
759 mj_core::config::validate_id("session", session_id)?;
760 std::fs::create_dir_all(directory).with_context(|| {
761 format!(
762 "create launch diagnostics directory {}",
763 directory.display()
764 )
765 })?;
766 #[cfg(unix)]
767 {
768 use std::os::unix::fs::PermissionsExt;
769 std::fs::set_permissions(directory, std::fs::Permissions::from_mode(0o700))?;
770 }
771 let path = directory.join(format!("{session_id}-launch-error.txt"));
772 let detail = bounded_launch_diagnostic(detail);
773 let body = format!(
774 "Hel session launch failure\nsession: {session_id}\nat: {}\n\n{detail}\n",
775 now()
776 );
777 atomic_write(&path, body.as_bytes())?;
778 prune_launch_diagnostics(directory)?;
779 Ok(path)
780}
781
782fn bounded_launch_diagnostic(detail: &str) -> String {
783 if detail.len() <= MAX_LAUNCH_DIAGNOSTIC_BYTES {
784 return detail.to_owned();
785 }
786 let mut head_end = MAX_LAUNCH_DIAGNOSTIC_BYTES / 4;
787 while !detail.is_char_boundary(head_end) {
788 head_end -= 1;
789 }
790 let tail_bytes = MAX_LAUNCH_DIAGNOSTIC_BYTES - head_end;
791 let mut tail_start = detail.len() - tail_bytes;
792 while !detail.is_char_boundary(tail_start) {
793 tail_start += 1;
794 }
795 format!(
796 "{}\n\n[... launch diagnostic truncated ...]\n\n{}",
797 &detail[..head_end],
798 &detail[tail_start..]
799 )
800}
801
802fn prune_launch_diagnostics(directory: &Path) -> Result<()> {
803 let mut diagnostics = Vec::new();
804 for entry in std::fs::read_dir(directory)? {
805 let entry = entry?;
806 if !entry
807 .file_name()
808 .to_str()
809 .is_some_and(|name| name.ends_with("-launch-error.txt"))
810 {
811 continue;
812 }
813 diagnostics.push((entry.metadata()?.modified()?, entry.path()));
814 }
815 diagnostics.sort_by_key(|entry| std::cmp::Reverse(entry.0));
816 for (_, path) in diagnostics.into_iter().skip(RETAINED_LAUNCH_DIAGNOSTICS) {
817 std::fs::remove_file(&path)
818 .with_context(|| format!("prune old launch diagnostic {}", path.display()))?;
819 }
820 Ok(())
821}
822
823fn apply_new_session_provisioning_result(
824 state: &mut State,
825 session_id: &str,
826 result: Result<TargetLocator>,
827) -> Result<()> {
828 match result {
829 Ok(locator) => {
830 let record = state.sessions.get_mut(session_id).unwrap();
831 record.target = Some(locator);
832 record.state = SessionState::Disconnected;
835 record.updated_at = now();
836 record.last_error = None;
837 Ok(())
838 }
839 Err(error) => {
840 let record = state.sessions.get_mut(session_id).unwrap();
841 record.state = SessionState::Error;
842 record.target = None;
843 record.updated_at = now();
844 record.last_error = Some(format!("session provisioning failed: {error:#}"));
845 Err(error)
846 }
847 }
848}
849
850pub(super) fn apply_failed_new_session_rollback(
851 state: &mut State,
852 session_id: &str,
853 original_error: &str,
854 cleanup_error: Option<String>,
855) -> anyhow::Error {
856 match cleanup_error {
857 None => {
858 let record = state.sessions.get_mut(session_id).unwrap();
859 record.state = SessionState::Error;
860 record.target = None;
861 record.updated_at = now();
862 record.last_error = Some(format!("worker bootstrap failed: {original_error}"));
863 anyhow::anyhow!("{original_error}; partial target removed and failed session retained")
864 }
865 Some(cleanup_error) => {
866 let failure = format!(
867 "{original_error}; cleanup of the failed session target failed: {cleanup_error}"
868 );
869 let record = state.sessions.get_mut(session_id).unwrap();
870 record.state = SessionState::Error;
871 record.updated_at = now();
872 record.last_error = Some(format!("worker bootstrap failed: {failure}"));
873 anyhow::anyhow!(failure)
874 }
875 }
876}
877
878pub(super) fn install_attached_resources(
879 state: &State,
880 session_id: &str,
881 backend: &targets::TargetLocator,
882 worker_root: &str,
883 executor: &impl CommandExecutor,
884) -> Result<()> {
885 let targets::TargetLocator::AwsEc2 { .. } = backend else {
886 return Ok(());
887 };
888 let session = state
889 .sessions
890 .get(session_id)
891 .with_context(|| format!("unknown session {session_id}"))?;
892 if session.additional_mounts.is_empty() {
893 return Ok(());
894 }
895 for resource in &session.additional_mounts {
896 let install = targets::command_on_locator(
897 backend,
898 session_id,
899 vec![
900 format!("{worker_root}/hel"),
901 "worker".into(),
902 "install-resource".into(),
903 "--destination".into(),
904 resource.destination.to_string_lossy().into_owned(),
905 ],
906 "stream attached resource",
907 )?;
908 mj_checkpoint::resources::stream_resource(&resource.source, |stream| {
909 execute_checked_with_stdin(executor, &install, stream).map(|_| ())
910 })
911 .with_context(|| format!("stream attached resource {}", resource.source.display()))?;
912 }
913 Ok(())
914}
915
916pub(super) fn execute_concurrent_lanes<A: Send, B: Send>(
920 first: impl FnOnce() -> Result<A> + Send,
921 second: impl FnOnce() -> Result<B> + Send,
922) -> Result<(A, B)> {
923 std::thread::scope(|scope| {
924 let second = scope.spawn(second);
925 let first = first();
926 let second = second.join().unwrap_or_else(|panic| {
927 Err(anyhow::anyhow!(
928 "concurrent target lane panicked: {}",
929 targets::command_thread_panic_message(panic.as_ref())
930 ))
931 });
932 match (first, second) {
933 (Err(error), _) => Err(error),
934 (Ok(_), Err(error)) => Err(error),
935 (Ok(first), Ok(second)) => Ok((first, second)),
936 }
937 })
938}
939
940fn execute_repository_setup(
941 plan: &targets::CommandPlan,
942 executor: &(impl CommandExecutor + Sync),
943) -> Result<()> {
944 if plan.commands.is_empty() {
945 return Ok(());
946 }
947 let _cloning = ProvisionStageGuard::new(executor, ProvisionStage::Cloning);
948 plan.execute_concurrent(executor).map(|_| ())
949}
950
951#[cfg(test)]
958fn provision_target(
959 plan: &targets::CommandPlan,
960 target: &targets::TargetTemplate,
961 session_id: &str,
962 executor: &(impl CommandExecutor + Sync),
963 discover: impl FnOnce(&[CommandOutput]) -> Result<TargetLocator>,
964) -> Result<TargetLocator> {
965 let Some((creation, remainder)) = plan.split_at_target_creation() else {
966 return discover(&plan.execute_concurrent(executor)?);
968 };
969 let mut outputs = creation.execute_concurrent(executor)?;
970 let result = match remainder.execute_concurrent(executor) {
971 Ok(rest) => {
972 outputs.extend(rest);
973 discover(&outputs)
974 }
975 Err(error) => Err(error),
976 };
977 result.map_err(|error| {
978 match cleanup_failed_provision(target, session_id, outputs.first(), executor) {
979 Some(note) => error.context(note),
980 None => error,
981 }
982 })
983}
984
985fn provision_target_creation(
989 plan: &targets::CommandPlan,
990 target: &targets::TargetTemplate,
991 session_id: &str,
992 executor: &(impl CommandExecutor + Sync),
993 discover: impl FnOnce(&[CommandOutput]) -> Result<TargetLocator>,
994) -> Result<(TargetLocator, targets::CommandPlan)> {
995 let Some((creation, remainder)) = plan.split_at_target_creation() else {
996 let outputs = crate::image_pull_gate::with_image_ready(target, executor, || {
999 plan.execute_concurrent(executor)
1000 })?;
1001 return discover(&outputs).map(|locator| {
1002 (
1003 locator,
1004 targets::CommandPlan {
1005 description: plan.description.clone(),
1006 commands: Vec::new(),
1007 },
1008 )
1009 });
1010 };
1011 let outputs = crate::image_pull_gate::with_image_ready(target, executor, || {
1015 creation.execute_concurrent(executor)
1016 })?;
1017 discover(&outputs)
1018 .map(|locator| (locator, remainder))
1019 .map_err(|error| {
1020 match cleanup_failed_provision(target, session_id, outputs.first(), executor) {
1021 Some(note) => error.context(note),
1022 None => error,
1023 }
1024 })
1025}
1026
1027fn cleanup_failed_provision(
1034 target: &targets::TargetTemplate,
1035 session_id: &str,
1036 create_output: Option<&CommandOutput>,
1037 executor: &impl CommandExecutor,
1038) -> Option<String> {
1039 let locator = provisioned_locator(target, session_id, create_output)?;
1040 let leak = format!(
1041 "the resource may still exist; find it via its dev.mj.session={session_id} label/tag"
1042 );
1043 let plan = match targets::close_plan(&locator, session_id) {
1044 Ok(plan) => plan,
1045 Err(error) => {
1046 tracing::warn!(
1047 session_id,
1048 error = format!("{error:#}"),
1049 "could not build provisioning cleanup plan"
1050 );
1051 return Some(format!("cleanup FAILED: {error:#}; {leak}"));
1052 }
1053 };
1054 let purpose = plan
1055 .commands
1056 .iter()
1057 .map(|command| command.purpose.clone())
1058 .collect::<Vec<_>>()
1059 .join("; ");
1060 let Err(error) = plan.execute(executor) else {
1061 return Some(format!("cleanup succeeded: {purpose}"));
1062 };
1063 match targets::cleanup_target_is_confirmed_absent(&locator, session_id, executor) {
1064 Ok(true) => Some(format!("cleanup succeeded: {purpose}")),
1065 Ok(false) => {
1066 tracing::warn!(
1067 session_id,
1068 error = format!("{error:#}"),
1069 "provisioning cleanup failed and the target may still exist"
1070 );
1071 Some(format!("cleanup FAILED ({purpose}): {error:#}; {leak}"))
1072 }
1073 Err(confirm_error) => {
1074 tracing::warn!(
1075 session_id,
1076 error = format!("{confirm_error:#}"),
1077 "could not confirm whether the failed provisioning target was removed"
1078 );
1079 Some(format!(
1080 "cleanup FAILED ({purpose}): {error:#}; checking whether it was removed also failed: {confirm_error:#}; {leak}"
1081 ))
1082 }
1083 }
1084}
1085
1086fn provisioned_locator(
1091 target: &targets::TargetTemplate,
1092 session_id: &str,
1093 create_output: Option<&CommandOutput>,
1094) -> Option<targets::TargetLocator> {
1095 let container_id = || targets::resource_name(session_id).ok();
1096 Some(match target {
1097 targets::TargetTemplate::LocalBare => return None,
1100 targets::TargetTemplate::LocalPodman(container) => targets::TargetLocator::LocalPodman {
1101 borrowed_from: None,
1102 container_id: container_id()?,
1103 workspace_storage: targets::podman_workspace_locator(container, session_id).ok()?,
1104 },
1105 targets::TargetTemplate::LocalDocker(_) => targets::TargetLocator::LocalDocker {
1106 borrowed_from: None,
1107 container_id: container_id()?,
1108 },
1109 targets::TargetTemplate::AppleContainer(_) => targets::TargetLocator::AppleContainer {
1110 borrowed_from: None,
1111 container_id: container_id()?,
1112 },
1113 targets::TargetTemplate::SshPodman { ssh, container } => {
1114 targets::TargetLocator::SshPodman {
1115 borrowed_from: None,
1116 ssh: ssh.clone(),
1117 container_id: container_id()?,
1118 workspace_storage: targets::podman_workspace_locator(container, session_id).ok()?,
1119 }
1120 }
1121 targets::TargetTemplate::SshDocker { ssh, .. } => targets::TargetLocator::SshDocker {
1122 borrowed_from: None,
1123 ssh: ssh.clone(),
1124 container_id: container_id()?,
1125 },
1126 targets::TargetTemplate::SshBare { ssh, .. } => targets::TargetLocator::SshBare {
1127 ssh: ssh.clone(),
1128 workspace: targets::workspace_for(target, session_id).ok()?,
1129 worker_id: None,
1130 },
1131 targets::TargetTemplate::AwsEc2(aws) => targets::TargetLocator::AwsEc2 {
1132 profile: aws.profile.clone(),
1133 region: aws.region.clone(),
1134 instance_id: serde_json::from_slice::<serde_json::Value>(&create_output?.stdout)
1135 .ok()?
1136 .pointer("/Instances/0/InstanceId")?
1137 .as_str()?
1138 .to_owned(),
1139 ssh: aws.ssh.clone(),
1140 workspace: targets::workspace_for(target, session_id).ok()?,
1141 },
1142 })
1143}
1144
1145pub(super) fn enforce_overlay_capable_mounts(
1156 target: &targets::TargetTemplate,
1157 mounts: &mut [targets::AdditionalMount],
1158 executor: &impl CommandExecutor,
1159) -> Vec<String> {
1160 let ssh = match target {
1161 targets::TargetTemplate::LocalPodman(_) | targets::TargetTemplate::LocalDocker(_) => None,
1162 targets::TargetTemplate::SshPodman { ssh, .. }
1163 | targets::TargetTemplate::SshDocker { ssh, .. } => Some(ssh),
1164 _ => return Vec::new(),
1165 };
1166 let overlaid = mounts
1167 .iter()
1168 .filter(|mount| mount.access == targets::MountAccess::Cow)
1169 .map(|mount| mount.source.clone())
1170 .collect::<Vec<_>>();
1171 if overlaid.is_empty() {
1172 return Vec::new();
1173 }
1174 let filesystems = match targets::probe_filesystem_types(ssh, &overlaid, executor) {
1175 Ok(filesystems) => filesystems,
1176 Err(error) => {
1177 tracing::warn!(
1178 error = format!("{error:#}"),
1179 "could not probe attached-directory filesystems; preserving overlay mounts"
1180 );
1181 return vec![format!(
1182 "Could not read the filesystem under the attached directories, so they keep the \
1183 copy-on-write overlay: {error:#}"
1184 )];
1185 }
1186 };
1187 let mut notices = Vec::new();
1188 for (mount, filesystem) in mounts
1189 .iter_mut()
1190 .filter(|mount| mount.access == targets::MountAccess::Cow)
1191 .zip(filesystems)
1192 {
1193 let Some(reason) = targets::overlay_unsupported_filesystem(&filesystem) else {
1194 continue;
1195 };
1196 mount.access = mount.access.without_overlay();
1197 notices.push(format!(
1198 "Mounted {} read-only: the overlay is unreliable on {filesystem} ({reason}).",
1199 mount.source.display()
1200 ));
1201 }
1202 notices
1203}
1204
1205static IMAGE_USERS: std::sync::LazyLock<std::sync::Mutex<BTreeMap<String, targets::ImageUser>>> =
1210 std::sync::LazyLock::new(std::sync::Mutex::default);
1211
1212pub(super) fn podman_image_user(
1219 target: &targets::TargetTemplate,
1220 executor: &impl CommandExecutor,
1221) -> Option<targets::ImageUser> {
1222 let (ssh, container) = match target {
1223 targets::TargetTemplate::LocalPodman(container) => (None, container),
1224 targets::TargetTemplate::SshPodman { ssh, container } => (Some(ssh), container),
1225 _ => return None,
1226 };
1227 let image = container.image.as_str();
1228 let key = format!(
1229 "{}|{image}",
1230 ssh.map_or("local", |ssh| ssh.destination.as_str())
1231 );
1232 if let Some(cached) = IMAGE_USERS.lock().expect("image user cache").get(&key) {
1233 return Some(*cached);
1234 }
1235 match crate::image_pull_gate::with_image_ready(target, executor, || {
1239 targets::probe_image_user(ssh, container, executor)
1240 }) {
1241 Ok(user) => {
1242 IMAGE_USERS
1243 .lock()
1244 .expect("image user cache")
1245 .insert(key, user);
1246 Some(user)
1247 }
1248 Err(error) => {
1249 tracing::warn!(
1250 image,
1251 error = format!("{error:#}"),
1252 "could not read the container image user; keeping Podman's default user mapping"
1253 );
1254 executor.notify_notice(&format!(
1255 "Could not read the user of image {image}, so the container runs with Podman's \
1256 default user mapping and may not be able to write to an attached directory: \
1257 {error:#}"
1258 ));
1259 None
1260 }
1261 }
1262}
1263
1264pub(super) struct StagedExecutor<'a, E: CommandExecutor> {
1268 inner: &'a E,
1269 stage: ProvisionStage,
1270 _guard: ProvisionStageGuard<'a, E>,
1271}
1272
1273impl<'a, E: CommandExecutor> StagedExecutor<'a, E> {
1274 pub(crate) fn new(inner: &'a E, stage: ProvisionStage) -> Self {
1275 Self {
1276 inner,
1277 stage,
1278 _guard: ProvisionStageGuard::new(inner, stage),
1279 }
1280 }
1281
1282 fn staged(&self, command: &CommandSpec) -> CommandSpec {
1283 if command.stage.is_some() {
1284 return command.clone();
1285 }
1286 command.clone().stage(self.stage)
1287 }
1288}
1289
1290impl<E: CommandExecutor> CommandExecutor for StagedExecutor<'_, E> {
1291 fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
1292 self.inner.execute(&self.staged(command))
1293 }
1294
1295 fn cancellation_requested(&self) -> bool {
1296 self.inner.cancellation_requested()
1297 }
1298
1299 fn stage_started(&self, stage: ProvisionStage) {
1300 self.inner.stage_started(stage);
1301 }
1302
1303 fn stage_finished(&self, stage: ProvisionStage) {
1304 self.inner.stage_finished(stage);
1305 }
1306
1307 fn notify_notice(&self, notice: &str) {
1308 self.inner.notify_notice(notice);
1309 }
1310
1311 fn execute_with_stdin(
1312 &self,
1313 command: &CommandSpec,
1314 input: &mut (dyn std::io::Read + Send),
1315 ) -> Result<CommandOutput> {
1316 self.inner.execute_with_stdin(&self.staged(command), input)
1317 }
1318}
1319
1320fn execute_checked_with_stdin(
1321 executor: &impl CommandExecutor,
1322 command: &CommandSpec,
1323 input: &mut (dyn std::io::Read + Send),
1324) -> Result<CommandOutput> {
1325 let output = executor.execute_with_stdin(command, input)?;
1326 if output.status != 0 {
1327 bail!(
1328 "{} failed with status {}: {}",
1329 command.purpose,
1330 output.status,
1331 String::from_utf8_lossy(&output.stderr)
1332 );
1333 }
1334 Ok(output)
1335}
1336
1337pub(super) fn install_inherited_git_settings(
1338 executor: &impl CommandExecutor,
1339 locator: &targets::TargetLocator,
1340 session_id: &str,
1341) -> Result<()> {
1342 let settings = if inherits_controller_git_settings(locator) {
1343 controller_git_settings()?
1344 } else {
1345 BTreeMap::new()
1346 };
1347 for command in inherited_git_setting_commands(locator, session_id, settings)? {
1348 execute_checked(executor, command)?;
1349 }
1350 Ok(())
1351}
1352
1353fn inherits_controller_git_settings(locator: &targets::TargetLocator) -> bool {
1354 !matches!(
1355 locator,
1356 targets::TargetLocator::LocalBare { .. } | targets::TargetLocator::SshBare { .. }
1357 )
1358}
1359
1360fn inherited_git_setting_commands(
1361 locator: &targets::TargetLocator,
1362 session_id: &str,
1363 settings: BTreeMap<String, String>,
1364) -> Result<Vec<CommandSpec>> {
1365 if matches!(locator, targets::TargetLocator::SshBare { .. }) {
1366 return Ok(Vec::new());
1367 }
1368 settings
1369 .into_iter()
1370 .map(|(key, value)| {
1371 targets::command_on_locator(
1372 locator,
1373 session_id,
1374 vec![
1375 "git".into(),
1376 "config".into(),
1377 "--global".into(),
1378 "--replace-all".into(),
1379 "--".into(),
1380 key.clone(),
1381 value,
1382 ],
1383 format!("inherit Git setting {key}"),
1384 )
1385 })
1386 .collect()
1387}
1388
1389fn controller_git_settings() -> Result<BTreeMap<String, String>> {
1390 let output = match Command::new("git")
1391 .args(["config", "--global", "--includes", "--null", "--list"])
1392 .stdin(Stdio::null())
1393 .output()
1394 {
1395 Ok(output) => output,
1396 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(BTreeMap::new()),
1397 Err(error) => return Err(error).context("read controller Git configuration"),
1398 };
1399 if !output.status.success() {
1400 bail!(
1401 "read controller Git configuration failed with status {}: {}",
1402 output.status,
1403 String::from_utf8_lossy(&output.stderr).trim()
1404 );
1405 }
1406 parse_inherited_git_settings(&output.stdout)
1407}
1408
1409fn parse_inherited_git_settings(output: &[u8]) -> Result<BTreeMap<String, String>> {
1410 let mut settings = BTreeMap::new();
1411 for entry in output
1412 .split(|byte| *byte == 0)
1413 .filter(|entry| !entry.is_empty())
1414 {
1415 let entry = std::str::from_utf8(entry).context("decode controller Git configuration")?;
1416 let (key, value) = entry
1417 .split_once('\n')
1418 .with_context(|| format!("controller Git returned malformed entry {entry:?}"))?;
1419 let key = key.to_ascii_lowercase();
1420 if INHERITED_GIT_SETTINGS.contains(&key.as_str()) {
1421 settings.insert(key, value.to_owned());
1422 }
1423 }
1424 Ok(settings)
1425}
1426
1427#[cfg(test)]
1428mod tests;