1use std::collections::BTreeMap;
4use std::path::{Path, PathBuf};
5use std::process::{Command, Stdio};
6use std::time::{Duration, Instant};
7
8use anyhow::{Context, Result, bail, ensure};
9
10use hel::hel_archive::{
11 ArchiveInput, BundleManifest, GitCollectionSpec, GitHistoryMode, SessionManifest, SystemGit,
12 TargetManifest, collect_git_snapshot, write_archive_atomic,
13};
14use hel::hel_checkpoint::RepositoryRestoreSpec;
15use hel::hel_config::{ProjectBundle, TargetTemplate, atomic_write, data_dir};
16use hel::hel_git_proxy::{GitBrokerSpec, broker_is_alive, running_broker_pid};
17use hel::hel_local_git::canonical_repository;
18use hel::hel_projection::canonical_session_from_materialized;
19use hel::hel_state::{HelState, SessionRecord, SessionState, TargetLocator};
20use hel::hel_targets::{
21 self, CancellableProcessExecutor, CommandExecutor, CommandOutput, CommandSpec, ProvisionStage,
22 ProvisionStageGuard,
23};
24
25use super::backend::{
26 ContainerOverrides, absolute_target_path, backend_bundle, backend_locator, backend_target,
27 configure_github_token_environment, controller_github_token, locator_after_provision,
28 preflight_target, use_github_https_urls,
29};
30use super::checkpoint::upload_checkpoint_spec;
31use super::git_cache;
32use super::readiness::{connect_started_worker, wait_for_native_session_in_stage};
33use super::worker_binary::{bridge_readiness_stage, start_worker, worker_probe_diagnosis};
34use super::{Controller, execute_checked, now, target_kind};
35
36const INHERITED_GIT_SETTINGS: &[&str] = &[
37 "diff.algorithm",
38 "fetch.prune",
39 "fetch.prunetags",
40 "init.defaultbranch",
41 "merge.conflictstyle",
42 "pull.ff",
43 "pull.rebase",
44 "push.autosetupremote",
45 "push.default",
46 "rebase.autostash",
47 "rerere.autoupdate",
48 "rerere.enabled",
49 "user.email",
50 "user.name",
51];
52
53#[derive(Debug, Clone, PartialEq, Eq)]
56pub(super) enum LocalBootstrap {
57 Seed,
60 SeedFrom(PathBuf),
64 Skip,
67}
68
69#[derive(Debug, Clone, Copy, PartialEq, Eq)]
70pub(super) enum ProvisioningFailureDisposition {
71 Discard,
73 Preserve,
75}
76
77impl Controller {
78 pub async fn provision_session_controlled(
79 &mut self,
80 session_id: &str,
81 executor: &(impl CommandExecutor + Sync),
82 ) -> Result<()> {
83 self.provision_session_controlled_with_commit(session_id, executor, || Ok(()))
84 .await
85 }
86
87 pub async fn provision_session_controlled_with_commit(
88 &mut self,
89 session_id: &str,
90 executor: &(impl CommandExecutor + Sync),
91 grant_commit: impl FnOnce() -> Result<()>,
92 ) -> Result<()> {
93 let github_token = controller_github_token();
94 let repositories = self
95 .provision_session_target_with_failure_disposition(
96 session_id,
97 executor,
98 github_token.as_deref(),
99 ProvisioningFailureDisposition::Discard,
100 )
101 .await?;
102 let setup = execute_concurrent_lanes(
103 || execute_repository_setup(&repositories, executor),
104 || self.install_worker_payload(session_id, executor),
105 );
106 let result = match setup {
107 Ok(((), (backend, worker_root))) => {
108 self.connect_and_start_worker(session_id, executor, &backend, &worker_root)
109 .await
110 }
111 Err(error) => Err(error),
112 };
113 match result {
114 Ok(native_session_id) => {
115 if let Err(error) = grant_commit() {
116 return Err(self.rollback_failed_new_session(session_id, error, executor)?);
117 }
118 self.mark_worker_connected(session_id, native_session_id)
119 }
120 Err(error) => Err(self.rollback_failed_new_session(session_id, error, executor)?),
121 }
122 }
123
124 fn rollback_failed_new_session(
125 &mut self,
126 session_id: &str,
127 error: anyhow::Error,
128 executor: &impl CommandExecutor,
129 ) -> Result<anyhow::Error> {
130 let session = self
131 .state
132 .sessions
133 .get(session_id)
134 .with_context(|| format!("unknown session {session_id}"))?
135 .clone();
136 let broker_cleanup = retire_git_broker(session_id);
140 let target_cleanup = match session.target.as_ref() {
141 Some(locator) => (|| -> Result<()> {
142 let backend = backend_locator(locator, &session, &self.config)?;
143 hel_targets::close_plan(&backend, session_id)?
144 .execute(&CancellableProcessExecutor::with_timeout(
147 Duration::from_secs(15),
148 ))
149 .map(|_| ())
150 })(),
151 None => Ok(()),
152 };
153 let worktree_cleanup =
154 self.cleanup_new_session_worktree_after_failure(session_id, executor);
155 let cleanup_error = [broker_cleanup, target_cleanup, worktree_cleanup]
156 .into_iter()
157 .filter_map(Result::err)
158 .map(|error| format!("{error:#}"))
159 .collect::<Vec<_>>()
160 .join("; ");
161 if !cleanup_error.is_empty() {
162 tracing::warn!(
163 session_id,
164 error = %cleanup_error,
165 "new-session rollback cleanup reported failures"
166 );
167 }
168 let original = format!("{error:#}");
169 let original = match persist_launch_failure(session_id, &original) {
170 Ok(path) => format!("{original}; full diagnostic saved to {}", path.display()),
171 Err(save_error) => {
172 format!("{original}; saving the local diagnostic failed: {save_error:#}")
173 }
174 };
175 let failure = apply_failed_new_session_rollback(
176 &mut self.state,
177 session_id,
178 &original,
179 (!cleanup_error.is_empty()).then_some(cleanup_error),
180 );
181 self.persist_session_state(session_id)?;
182 Ok(failure)
183 }
184
185 pub async fn provision_session_with(
186 &mut self,
187 session_id: &str,
188 executor: &(impl CommandExecutor + Sync),
189 ) -> Result<()> {
190 self.provision_session_with_github_token(session_id, executor, None)
191 .await
192 }
193
194 async fn provision_session_with_github_token(
195 &mut self,
196 session_id: &str,
197 executor: &(impl CommandExecutor + Sync),
198 github_token: Option<&str>,
199 ) -> Result<()> {
200 self.provision_session_with_failure_disposition(
201 session_id,
202 executor,
203 github_token,
204 ProvisioningFailureDisposition::Discard,
205 )
206 .await
207 }
208
209 pub(super) async fn provision_session_with_failure_disposition(
210 &mut self,
211 session_id: &str,
212 executor: &(impl CommandExecutor + Sync),
213 github_token: Option<&str>,
214 failure_disposition: ProvisioningFailureDisposition,
215 ) -> Result<()> {
216 let repositories = self
217 .provision_session_target_with_failure_disposition(
218 session_id,
219 executor,
220 github_token,
221 failure_disposition,
222 )
223 .await?;
224 match execute_repository_setup(&repositories, executor) {
225 Ok(()) => Ok(()),
226 Err(error) if failure_disposition == ProvisioningFailureDisposition::Discard => {
227 Err(self.rollback_failed_new_session(session_id, error, executor)?)
228 }
229 Err(error) => Err(error),
230 }
231 }
232
233 async fn provision_session_target_with_failure_disposition(
234 &mut self,
235 session_id: &str,
236 executor: &(impl CommandExecutor + Sync),
237 github_token: Option<&str>,
238 failure_disposition: ProvisioningFailureDisposition,
239 ) -> Result<hel_targets::CommandPlan> {
240 let session = self
241 .state
242 .sessions
243 .get(session_id)
244 .with_context(|| format!("unknown session {session_id}"))?
245 .clone();
246 if session.state != SessionState::Provisioning {
247 bail!("session {session_id} is not provisioning");
248 }
249 let created_worktree = match self.prepare_managed_raw_worktree(session_id, executor) {
250 Ok(created) => created,
251 Err(error) if failure_disposition == ProvisioningFailureDisposition::Discard => {
252 return Err(self.fail_new_session_with_cleanup(session_id, error, executor)?);
253 }
254 Err(error) => return Err(error),
255 };
256 let session = self
257 .state
258 .sessions
259 .get(session_id)
260 .expect("session retained after managed worktree preparation")
261 .clone();
262 let result = (|| {
265 let template = self
266 .config
267 .targets
268 .get(&session.target_template_id)
269 .context("target template disappeared during provisioning")?;
270 if matches!(template, TargetTemplate::AwsEc2 { .. }) {
271 for resource in &session.additional_mounts {
272 ensure!(
273 resource.source.is_dir(),
274 "attached resource source is not a directory: {}",
275 resource.source.display()
276 );
277 }
278 }
279 let mut target = backend_target(
280 template,
281 session.resource_allocation.as_ref(),
282 ContainerOverrides::for_session(&session),
283 )?;
284 let mut runtime_mounts = if matches!(target, hel_targets::TargetTemplate::AwsEc2(_)) {
285 Vec::new()
286 } else {
287 session.additional_mounts.clone()
288 };
289 for notice in enforce_overlay_capable_mounts(&target, &mut runtime_mounts, executor) {
294 executor.notify_notice(¬ice);
295 }
296 let mut bundle = session
297 .project_directory
298 .is_none()
299 .then(|| self.config.bundles.get(&session.bundle_id))
300 .flatten()
301 .map(backend_bundle)
302 .transpose()?;
303 let container_github_token =
304 github_token.filter(|_| configure_github_token_environment(&mut target));
305 if container_github_token.is_some()
306 && let Some(bundle) = bundle.as_mut()
307 {
308 use_github_https_urls(bundle);
309 }
310 preflight_target(template, executor)?;
311 let prepared_cache = bundle.as_mut().and_then(|bundle| {
312 git_cache::prepare(
313 &target,
314 session_id,
315 bundle,
316 &mut runtime_mounts,
317 container_github_token,
318 executor,
319 )
320 });
321 let provision = if let Some(project_directory) = &session.project_directory {
322 hel_targets::provision_bare_project_plan(
323 &target,
324 session_id,
325 &project_directory.to_string_lossy(),
326 )
327 } else {
328 bundle
329 .as_ref()
330 .context("project bundle disappeared during provisioning")
331 .and_then(|bundle| {
332 hel_targets::provision_plan(&target, session_id, bundle, &runtime_mounts)
333 })
334 };
335 let mut provision = match provision {
336 Ok(provision) => provision,
337 Err(error) => {
338 if let Some(cache) = &prepared_cache {
339 let _ = cache.cleanup(executor);
340 }
341 return Err(error);
342 }
343 };
344 if let Some(token) = container_github_token
345 && let Err(error) =
346 provision.provide_target_environment_secret(&target, "GH_TOKEN", token)
347 {
348 if let Some(cache) = &prepared_cache {
349 let _ = cache.cleanup(executor);
350 }
351 return Err(error);
352 }
353
354 let started = Instant::now();
355 let result =
356 provision_target_creation(&provision, &target, session_id, executor, |outputs| {
357 locator_after_provision(
358 template,
359 &target,
360 session_id,
361 outputs.first(),
362 executor,
363 )
364 })
365 .map(|(locator, remainder)| (locator, remainder, bundle));
366 if result.is_err()
367 && let Some(cache) = &prepared_cache
368 {
369 if let Some(locator) = provisioned_locator(&target, session_id, None) {
370 let _ = hel_targets::close_plan(&locator, session_id)
371 .and_then(|plan| plan.execute(executor).map(|_| ()));
372 } else {
373 let _ = cache.cleanup(executor);
374 }
375 }
376 tracing::debug!(
377 session_id,
378 elapsed_ms = started.elapsed().as_millis(),
379 "provisioning plan execution completed"
380 );
381 result
382 })();
383 let result = match result {
384 Err(error)
385 if created_worktree
386 && failure_disposition == ProvisioningFailureDisposition::Discard =>
387 {
388 return Err(self.fail_new_session_with_cleanup(session_id, error, executor)?);
389 }
390 Err(error) if failure_disposition == ProvisioningFailureDisposition::Preserve => {
391 Err(error)
392 }
393 Err(error) => {
394 let error = match apply_new_session_provisioning_result(
395 &mut self.state,
396 session_id,
397 Err(error),
398 ) {
399 Ok(()) => unreachable!("an unsuccessful provisioning result returned Ok"),
400 Err(error) => error,
401 };
402 return match self.persist_session_state(session_id) {
403 Ok(()) => Err(error),
404 Err(persistence_error) => Err(error.context(format!(
405 "persist removal of failed provisioning session {session_id}: {persistence_error:#}"
406 ))),
407 };
408 }
409 Ok((locator, remainder, bundle)) => {
410 apply_new_session_provisioning_result(&mut self.state, session_id, Ok(locator))?;
411 let session = &self.state.sessions[session_id];
412 let backend = backend_locator(
413 session
414 .target
415 .as_ref()
416 .context("provisioned target disappeared")?,
417 session,
418 &self.config,
419 )?;
420 if matches!(backend, hel_targets::TargetLocator::AwsEc2 { .. }) {
421 hel_targets::provision_on_locator_plan(
422 &backend,
423 session_id,
424 bundle
425 .as_ref()
426 .context("AWS provisioning requires a project bundle")?,
427 )
428 } else {
429 Ok(remainder)
430 }
431 }
432 };
433 let result = match result {
434 Err(error) if failure_disposition == ProvisioningFailureDisposition::Discard => {
435 return Err(self.rollback_failed_new_session(session_id, error, executor)?);
436 }
437 result => result,
438 };
439 if result.is_ok()
440 && let Some(session) = self.state.sessions.get(session_id)
441 && let Some(directory) = session
442 .managed_worktree
443 .as_ref()
444 .map(|worktree| worktree.source_project_directory.clone())
445 .or_else(|| session.project_directory.clone())
446 && let Some(template) = self.config.targets.get(&session.target_template_id)
447 {
448 let host = match template {
449 TargetTemplate::LocalBare => Some("local"),
450 TargetTemplate::SshBare { ssh, .. } => Some(ssh.host.as_str()),
451 _ => None,
452 };
453 if let Some(host) = host {
454 self.state.remember_project_directory(host, &directory);
455 hel::hel_database::remember_project_directory(host, &directory)?;
456 }
457 }
458 self.persist_session_state(session_id)?;
459 result
460 }
461
462 pub fn mark_worker_connected(
463 &mut self,
464 session_id: &str,
465 native_session_id: Option<String>,
466 ) -> Result<()> {
467 let session = self
468 .state
469 .sessions
470 .get(session_id)
471 .with_context(|| format!("unknown session {session_id}"))?;
472 if session.target.is_none() {
473 bail!("session {session_id} has no provisioned target");
474 }
475 let updated_at = now();
476 hel::hel_database::mark_session_worker_connected(
477 session_id,
478 native_session_id.as_deref(),
479 &updated_at,
480 )?;
481 let session = self
482 .state
483 .sessions
484 .get_mut(session_id)
485 .expect("session disappeared after its worker connection was saved");
486 session.state = SessionState::Running;
487 if native_session_id.is_some() {
488 session.native_session_id = native_session_id;
489 }
490 session.updated_at = updated_at;
491 session.last_error = None;
492 Ok(())
493 }
494
495 fn install_worker_payload(
496 &self,
497 session_id: &str,
498 executor: &impl CommandExecutor,
499 ) -> Result<(hel_targets::TargetLocator, String)> {
500 let syncing = &StagedExecutor::new(executor, ProvisionStage::Syncing);
502 let (backend, worker_root) = self.worker_placement(session_id)?;
503 self.prepare_worker_files(session_id, &backend, &worker_root, syncing)?;
504 install_attached_resources(&self.state, session_id, &backend, &worker_root, syncing)?;
505 Ok((backend, worker_root))
506 }
507
508 async fn connect_and_start_worker(
509 &self,
510 session_id: &str,
511 executor: &impl CommandExecutor,
512 backend: &hel_targets::TargetLocator,
513 worker_root: &str,
514 ) -> Result<Option<String>> {
515 {
518 let syncing = &StagedExecutor::new(executor, ProvisionStage::Syncing);
519 install_inherited_git_settings(executor, backend, session_id)?;
520 self.connect_local_repositories(
521 session_id,
522 backend,
523 worker_root,
524 syncing,
525 LocalBootstrap::Seed,
526 )?;
527 }
528 let session = self
529 .state
530 .sessions
531 .get(session_id)
532 .with_context(|| format!("unknown session {session_id}"))?;
533 let profile = self
534 .config
535 .profiles
536 .get(&session.last_profile)
537 .with_context(|| format!("unknown profile {}", session.last_profile))?;
538 let readiness_stage = bridge_readiness_stage(profile);
539 let reconnect = &hel_targets::reconnect_plan(backend, session_id)?.commands[0];
540 let readiness = async {
541 let mut relay = {
542 let _starting = ProvisionStageGuard::new(executor, ProvisionStage::Starting);
543 start_worker(executor, backend, worker_root)?;
544 connect_started_worker(reconnect, session_id, executor, backend, worker_root)
545 .await?
546 };
547 let native_session_id =
548 wait_for_native_session_in_stage(&mut relay, executor, readiness_stage).await?;
549 Ok(Some(native_session_id))
550 }
551 .await;
552 match readiness {
553 Ok(native_session_id) => Ok(native_session_id),
554 Err(error) => Err(worker_probe_diagnosis(
555 executor,
556 backend,
557 worker_root,
558 error,
559 )),
560 }
561 }
562
563 pub(super) fn connect_local_repositories(
567 &self,
568 session_id: &str,
569 backend: &hel_targets::TargetLocator,
570 worker_root: &str,
571 executor: &impl CommandExecutor,
572 bootstrap: LocalBootstrap,
573 ) -> Result<()> {
574 let session = self
575 .state
576 .sessions
577 .get(session_id)
578 .with_context(|| format!("unknown session {session_id}"))?;
579 if session.project_directory.is_some() {
580 return Ok(());
581 }
582 let bundle = self
583 .config
584 .bundles
585 .get(&session.bundle_id)
586 .context("session bundle is missing")?;
587 let local = bundle
588 .repositories
589 .iter()
590 .filter_map(|repository| repository.local.as_ref().map(|path| (repository, path)))
591 .collect::<Vec<_>>();
592 if local.is_empty() {
593 return Ok(());
594 }
595
596 let absolute_worker_root =
597 absolute_target_path(executor, backend, session_id, worker_root)?;
598 let repositories = local
599 .iter()
600 .map(|(repository, path)| Ok((repository.id.clone(), canonical_repository(path)?)))
601 .collect::<Result<BTreeMap<_, _>>>()?;
602 ensure_git_broker(session_id, backend, repositories)?;
603
604 let workspace_root = match backend {
605 hel_targets::TargetLocator::LocalPodman { .. }
606 | hel_targets::TargetLocator::LocalDocker { .. }
607 | hel_targets::TargetLocator::AppleContainer { .. }
608 | hel_targets::TargetLocator::SshPodman { .. }
609 | hel_targets::TargetLocator::SshDocker { .. } => "/workspace".to_owned(),
610 hel_targets::TargetLocator::AwsEc2 { workspace, .. }
611 | hel_targets::TargetLocator::SshBare { workspace, .. } => workspace.clone(),
612 hel_targets::TargetLocator::LocalBare { worker_root } => worker_root.clone(),
613 };
614 let mut missing = Vec::new();
615 for &(repository, source) in &local {
616 local_branch(source)?;
617 let destination = format!(
618 "{workspace_root}/{}",
619 repository.destination.to_string_lossy()
620 );
621 let origin = local_origin_url(&absolute_worker_root, &repository.id);
622 for (args, purpose) in [
623 (
624 vec![
625 "git".into(),
626 "-C".into(),
627 destination.clone(),
628 "config".into(),
629 "protocol.ext.allow".into(),
630 "always".into(),
631 ],
632 "enable the confined local Git transport",
633 ),
634 (
635 vec![
636 "git".into(),
637 "-C".into(),
638 destination.clone(),
639 "config".into(),
640 "remote.origin.url".into(),
641 origin,
642 ],
643 "configure local Git origin",
644 ),
645 (
646 vec![
647 "git".into(),
648 "-C".into(),
649 destination.clone(),
650 "config".into(),
651 "remote.origin.fetch".into(),
652 "+refs/heads/*:refs/remotes/origin/*".into(),
653 ],
654 "configure local Git fetch refspec",
655 ),
656 ] {
657 execute_checked(
658 executor,
659 hel_targets::command_on_locator(backend, session_id, args, purpose)?,
660 )?;
661 }
662 let has_head = executor.execute(&hel_targets::command_on_locator(
663 backend,
664 session_id,
665 vec![
666 "git".into(),
667 "-C".into(),
668 destination.clone(),
669 "rev-parse".into(),
670 "--verify".into(),
671 "HEAD".into(),
672 ],
673 "inspect local Git bootstrap state",
674 )?)?;
675 if has_head.status != 0 {
676 missing.push((repository, source));
677 }
678 }
679 for (repository, _) in &local {
683 let destination = format!(
684 "{workspace_root}/{}",
685 repository.destination.to_string_lossy()
686 );
687 execute_checked(
688 executor,
689 hel_targets::command_on_locator(
690 backend,
691 session_id,
692 vec![
693 "git".into(),
694 "-C".into(),
695 destination.clone(),
696 "fetch".into(),
697 "origin".into(),
698 ],
699 "fetch local Git origin",
700 )?,
701 )?;
702 }
703 if let Some(sources) = seed_sources(&missing, &bootstrap)
704 && !sources.is_empty()
705 {
706 bootstrap_local_repositories(
707 executor,
708 backend,
709 session,
710 bundle,
711 &workspace_root,
712 worker_root,
713 &sources,
714 )?;
715 }
716 Ok(())
717 }
718}
719
720const MAX_LAUNCH_DIAGNOSTIC_BYTES: usize = 64 * 1024;
721
722const RETAINED_LAUNCH_DIAGNOSTICS: usize = 20;
723
724fn persist_launch_failure(session_id: &str, detail: &str) -> Result<PathBuf> {
725 persist_launch_failure_to(&data_dir().join("diagnostics"), session_id, detail)
726}
727
728fn persist_launch_failure_to(directory: &Path, session_id: &str, detail: &str) -> Result<PathBuf> {
729 hel::hel_config::validate_id("session", session_id)?;
730 std::fs::create_dir_all(directory).with_context(|| {
731 format!(
732 "create launch diagnostics directory {}",
733 directory.display()
734 )
735 })?;
736 #[cfg(unix)]
737 {
738 use std::os::unix::fs::PermissionsExt;
739 std::fs::set_permissions(directory, std::fs::Permissions::from_mode(0o700))?;
740 }
741 let path = directory.join(format!("{session_id}-launch-error.txt"));
742 let detail = bounded_launch_diagnostic(detail);
743 let body = format!(
744 "Hel session launch failure\nsession: {session_id}\nat: {}\n\n{detail}\n",
745 now()
746 );
747 atomic_write(&path, body.as_bytes())?;
748 prune_launch_diagnostics(directory)?;
749 Ok(path)
750}
751
752fn bounded_launch_diagnostic(detail: &str) -> String {
753 if detail.len() <= MAX_LAUNCH_DIAGNOSTIC_BYTES {
754 return detail.to_owned();
755 }
756 let mut head_end = MAX_LAUNCH_DIAGNOSTIC_BYTES / 4;
757 while !detail.is_char_boundary(head_end) {
758 head_end -= 1;
759 }
760 let tail_bytes = MAX_LAUNCH_DIAGNOSTIC_BYTES - head_end;
761 let mut tail_start = detail.len() - tail_bytes;
762 while !detail.is_char_boundary(tail_start) {
763 tail_start += 1;
764 }
765 format!(
766 "{}\n\n[... launch diagnostic truncated ...]\n\n{}",
767 &detail[..head_end],
768 &detail[tail_start..]
769 )
770}
771
772fn prune_launch_diagnostics(directory: &Path) -> Result<()> {
773 let mut diagnostics = Vec::new();
774 for entry in std::fs::read_dir(directory)? {
775 let entry = entry?;
776 if !entry
777 .file_name()
778 .to_str()
779 .is_some_and(|name| name.ends_with("-launch-error.txt"))
780 {
781 continue;
782 }
783 diagnostics.push((entry.metadata()?.modified()?, entry.path()));
784 }
785 diagnostics.sort_by_key(|entry| std::cmp::Reverse(entry.0));
786 for (_, path) in diagnostics.into_iter().skip(RETAINED_LAUNCH_DIAGNOSTICS) {
787 std::fs::remove_file(&path)
788 .with_context(|| format!("prune old launch diagnostic {}", path.display()))?;
789 }
790 Ok(())
791}
792
793fn apply_new_session_provisioning_result(
794 state: &mut HelState,
795 session_id: &str,
796 result: Result<TargetLocator>,
797) -> Result<()> {
798 match result {
799 Ok(locator) => {
800 let record = state.sessions.get_mut(session_id).unwrap();
801 record.target = Some(locator);
802 record.state = SessionState::Disconnected;
805 record.updated_at = now();
806 record.last_error = None;
807 Ok(())
808 }
809 Err(error) => {
810 state.sessions.remove(session_id);
811 Err(error)
812 }
813 }
814}
815
816pub(super) fn apply_failed_new_session_rollback(
817 state: &mut HelState,
818 session_id: &str,
819 original_error: &str,
820 cleanup_error: Option<String>,
821) -> anyhow::Error {
822 match cleanup_error {
823 None => {
824 state.sessions.remove(session_id);
825 anyhow::anyhow!(
826 "{original_error}; partial target removed and provisional session discarded"
827 )
828 }
829 Some(cleanup_error) => {
830 let failure = format!(
831 "{original_error}; cleanup of the failed session target failed: {cleanup_error}"
832 );
833 let record = state.sessions.get_mut(session_id).unwrap();
834 record.state = SessionState::Error;
835 record.updated_at = now();
836 record.last_error = Some(format!("worker bootstrap failed: {failure}"));
837 anyhow::anyhow!(failure)
838 }
839 }
840}
841
842pub(super) fn install_attached_resources(
843 state: &HelState,
844 session_id: &str,
845 backend: &hel_targets::TargetLocator,
846 worker_root: &str,
847 executor: &impl CommandExecutor,
848) -> Result<()> {
849 let hel_targets::TargetLocator::AwsEc2 { .. } = backend else {
850 return Ok(());
851 };
852 let session = state
853 .sessions
854 .get(session_id)
855 .with_context(|| format!("unknown session {session_id}"))?;
856 if session.additional_mounts.is_empty() {
857 return Ok(());
858 }
859 for resource in &session.additional_mounts {
860 let install = hel_targets::command_on_locator(
861 backend,
862 session_id,
863 vec![
864 format!("{worker_root}/hel"),
865 "worker".into(),
866 "install-resource".into(),
867 "--destination".into(),
868 resource.destination.to_string_lossy().into_owned(),
869 ],
870 "stream attached resource",
871 )?;
872 hel::hel_resources::stream_resource(&resource.source, |stream| {
873 execute_checked_with_stdin(executor, &install, stream).map(|_| ())
874 })
875 .with_context(|| format!("stream attached resource {}", resource.source.display()))?;
876 }
877 Ok(())
878}
879
880pub(super) fn execute_concurrent_lanes<A: Send, B: Send>(
884 first: impl FnOnce() -> Result<A> + Send,
885 second: impl FnOnce() -> Result<B> + Send,
886) -> Result<(A, B)> {
887 std::thread::scope(|scope| {
888 let second = scope.spawn(second);
889 let first = first();
890 let second = second.join().unwrap_or_else(|panic| {
891 Err(anyhow::anyhow!(
892 "concurrent target lane panicked: {}",
893 hel_targets::command_thread_panic_message(panic.as_ref())
894 ))
895 });
896 match (first, second) {
897 (Err(error), _) => Err(error),
898 (Ok(_), Err(error)) => Err(error),
899 (Ok(first), Ok(second)) => Ok((first, second)),
900 }
901 })
902}
903
904fn execute_repository_setup(
905 plan: &hel_targets::CommandPlan,
906 executor: &(impl CommandExecutor + Sync),
907) -> Result<()> {
908 if plan.commands.is_empty() {
909 return Ok(());
910 }
911 let _cloning = ProvisionStageGuard::new(executor, ProvisionStage::Cloning);
912 plan.execute_concurrent(executor).map(|_| ())
913}
914
915#[cfg(test)]
922fn provision_target(
923 plan: &hel_targets::CommandPlan,
924 target: &hel_targets::TargetTemplate,
925 session_id: &str,
926 executor: &(impl CommandExecutor + Sync),
927 discover: impl FnOnce(&[CommandOutput]) -> Result<TargetLocator>,
928) -> Result<TargetLocator> {
929 let Some((creation, remainder)) = plan.split_at_target_creation() else {
930 return discover(&plan.execute_concurrent(executor)?);
932 };
933 let mut outputs = creation.execute_concurrent(executor)?;
934 let result = match remainder.execute_concurrent(executor) {
935 Ok(rest) => {
936 outputs.extend(rest);
937 discover(&outputs)
938 }
939 Err(error) => Err(error),
940 };
941 result.map_err(|error| {
942 match cleanup_failed_provision(target, session_id, outputs.first(), executor) {
943 Some(note) => error.context(note),
944 None => error,
945 }
946 })
947}
948
949fn provision_target_creation(
953 plan: &hel_targets::CommandPlan,
954 target: &hel_targets::TargetTemplate,
955 session_id: &str,
956 executor: &(impl CommandExecutor + Sync),
957 discover: impl FnOnce(&[CommandOutput]) -> Result<TargetLocator>,
958) -> Result<(TargetLocator, hel_targets::CommandPlan)> {
959 let Some((creation, remainder)) = plan.split_at_target_creation() else {
960 let outputs = plan.execute_concurrent(executor)?;
963 return discover(&outputs).map(|locator| {
964 (
965 locator,
966 hel_targets::CommandPlan {
967 description: plan.description.clone(),
968 commands: Vec::new(),
969 },
970 )
971 });
972 };
973 let outputs = creation.execute_concurrent(executor)?;
974 discover(&outputs)
975 .map(|locator| (locator, remainder))
976 .map_err(|error| {
977 match cleanup_failed_provision(target, session_id, outputs.first(), executor) {
978 Some(note) => error.context(note),
979 None => error,
980 }
981 })
982}
983
984fn cleanup_failed_provision(
991 target: &hel_targets::TargetTemplate,
992 session_id: &str,
993 create_output: Option<&CommandOutput>,
994 executor: &impl CommandExecutor,
995) -> Option<String> {
996 let locator = provisioned_locator(target, session_id, create_output)?;
997 let leak = format!(
998 "the resource may still exist; find it via its dev.mj.session={session_id} label/tag"
999 );
1000 let plan = match hel_targets::close_plan(&locator, session_id) {
1001 Ok(plan) => plan,
1002 Err(error) => {
1003 tracing::warn!(
1004 session_id,
1005 error = format!("{error:#}"),
1006 "could not build provisioning cleanup plan"
1007 );
1008 return Some(format!("cleanup FAILED: {error:#}; {leak}"));
1009 }
1010 };
1011 let purpose = plan
1012 .commands
1013 .iter()
1014 .map(|command| command.purpose.clone())
1015 .collect::<Vec<_>>()
1016 .join("; ");
1017 let Err(error) = plan.execute(executor) else {
1018 return Some(format!("cleanup succeeded: {purpose}"));
1019 };
1020 match hel_targets::cleanup_target_is_confirmed_absent(&locator, session_id, executor) {
1021 Ok(true) => Some(format!("cleanup succeeded: {purpose}")),
1022 Ok(false) => {
1023 tracing::warn!(
1024 session_id,
1025 error = format!("{error:#}"),
1026 "provisioning cleanup failed and the target may still exist"
1027 );
1028 Some(format!("cleanup FAILED ({purpose}): {error:#}; {leak}"))
1029 }
1030 Err(confirm_error) => {
1031 tracing::warn!(
1032 session_id,
1033 error = format!("{confirm_error:#}"),
1034 "could not confirm whether the failed provisioning target was removed"
1035 );
1036 Some(format!(
1037 "cleanup FAILED ({purpose}): {error:#}; checking whether it was removed also failed: {confirm_error:#}; {leak}"
1038 ))
1039 }
1040 }
1041}
1042
1043fn provisioned_locator(
1048 target: &hel_targets::TargetTemplate,
1049 session_id: &str,
1050 create_output: Option<&CommandOutput>,
1051) -> Option<hel_targets::TargetLocator> {
1052 let container_id = || hel_targets::resource_name(session_id).ok();
1053 Some(match target {
1054 hel_targets::TargetTemplate::LocalBare => return None,
1057 hel_targets::TargetTemplate::LocalPodman(container) => {
1058 hel_targets::TargetLocator::LocalPodman {
1059 container_id: container_id()?,
1060 workspace_storage: hel_targets::podman_workspace_locator(container, session_id)
1061 .ok()?,
1062 }
1063 }
1064 hel_targets::TargetTemplate::LocalDocker(_) => hel_targets::TargetLocator::LocalDocker {
1065 container_id: container_id()?,
1066 },
1067 hel_targets::TargetTemplate::AppleContainer(_) => {
1068 hel_targets::TargetLocator::AppleContainer {
1069 container_id: container_id()?,
1070 }
1071 }
1072 hel_targets::TargetTemplate::SshPodman { ssh, container } => {
1073 hel_targets::TargetLocator::SshPodman {
1074 ssh: ssh.clone(),
1075 container_id: container_id()?,
1076 workspace_storage: hel_targets::podman_workspace_locator(container, session_id)
1077 .ok()?,
1078 }
1079 }
1080 hel_targets::TargetTemplate::SshDocker { ssh, .. } => {
1081 hel_targets::TargetLocator::SshDocker {
1082 ssh: ssh.clone(),
1083 container_id: container_id()?,
1084 }
1085 }
1086 hel_targets::TargetTemplate::SshBare { ssh, .. } => hel_targets::TargetLocator::SshBare {
1087 ssh: ssh.clone(),
1088 workspace: hel_targets::workspace_for(target, session_id).ok()?,
1089 },
1090 hel_targets::TargetTemplate::AwsEc2(aws) => hel_targets::TargetLocator::AwsEc2 {
1091 profile: aws.profile.clone(),
1092 region: aws.region.clone(),
1093 instance_id: serde_json::from_slice::<serde_json::Value>(&create_output?.stdout)
1094 .ok()?
1095 .pointer("/Instances/0/InstanceId")?
1096 .as_str()?
1097 .to_owned(),
1098 ssh: aws.ssh.clone(),
1099 workspace: hel_targets::workspace_for(target, session_id).ok()?,
1100 },
1101 })
1102}
1103
1104fn local_branch(repository: &Path) -> Result<String> {
1105 let output = Command::new("git")
1106 .args(["symbolic-ref", "--quiet", "--short", "HEAD"])
1107 .current_dir(repository)
1108 .output()
1109 .with_context(|| format!("read current branch in {}", repository.display()))?;
1110 if !output.status.success() {
1111 bail!(
1112 "local repository {} must have a branch checked out before Hel can expose it as origin",
1113 repository.display()
1114 );
1115 }
1116 let branch = String::from_utf8(output.stdout).context("decode local Git branch")?;
1117 let branch = branch.trim().to_owned();
1118 if branch.is_empty() {
1119 bail!("local repository has an empty current branch");
1120 }
1121 Ok(branch)
1122}
1123
1124fn local_origin_url(worker_root: &str, repository_id: &str) -> String {
1125 fn ext_argument(value: &str) -> String {
1126 value.replace('%', "%%").replace(' ', "% ")
1127 }
1128 format!(
1129 "ext::{}/hel worker git-proxy --root {} --repository {} %S",
1130 ext_argument(worker_root),
1131 ext_argument(worker_root),
1132 repository_id,
1133 )
1134}
1135
1136fn seed_sources<'a>(
1137 missing: &[(&'a hel::hel_config::ProjectRepository, &'a PathBuf)],
1138 bootstrap: &'a LocalBootstrap,
1139) -> Option<Vec<(&'a hel::hel_config::ProjectRepository, &'a PathBuf)>> {
1140 let checkout = match bootstrap {
1141 LocalBootstrap::Skip => return None,
1142 LocalBootstrap::Seed => None,
1143 LocalBootstrap::SeedFrom(checkout) => Some(checkout),
1144 };
1145 Some(
1146 missing
1147 .iter()
1148 .map(|(repository, source)| (*repository, checkout.unwrap_or(source)))
1149 .collect(),
1150 )
1151}
1152
1153fn bootstrap_local_repositories(
1157 executor: &impl CommandExecutor,
1158 locator: &hel_targets::TargetLocator,
1159 session: &SessionRecord,
1160 bundle: &ProjectBundle,
1161 workspace_root: &str,
1162 worker_root: &str,
1163 repositories: &[(&hel::hel_config::ProjectRepository, &PathBuf)],
1164) -> Result<()> {
1165 let snapshots = repositories
1166 .iter()
1167 .map(|(repository, source)| {
1168 collect_git_snapshot(
1169 &SystemGit,
1170 source,
1171 &GitCollectionSpec {
1172 id: repository.id.clone(),
1173 relative_destination: repository.destination.clone(),
1174 history: GitHistoryMode::NoBundle,
1175 origin_override: Some(format!("mj-local:{}", repository.id)),
1176 },
1177 )
1178 .with_context(|| format!("snapshot local repository {:?}", repository.id))
1179 })
1180 .collect::<Result<Vec<_>>>()?;
1181 let staging = data_dir().join("git-seeds");
1182 std::fs::create_dir_all(&staging)?;
1183 let archive_path = staging.join(format!("{}.hel.zip", session.id));
1184 write_archive_atomic(
1185 &archive_path,
1186 &ArchiveInput {
1187 session: SessionManifest {
1188 id: session.id.clone(),
1189 title: session.title.clone(),
1190 harness_kind: session.harness_kind,
1191 profile_id: session.last_profile.clone(),
1192 native_session_id: session.native_session_id.clone().unwrap_or_default(),
1193 created_at: session.created_at.clone(),
1194 checkpointed_at: now(),
1195 hel_version: env!("CARGO_PKG_VERSION").into(),
1196 relay_version: env!("CARGO_PKG_VERSION").into(),
1197 adapter_version: "acp-v1".into(),
1198 },
1199 target: TargetManifest {
1200 template_id: session.target_template_id.clone(),
1201 target_kind: target_kind(locator).into(),
1202 details: Default::default(),
1203 },
1204 bundle: BundleManifest {
1205 id: session.bundle_id.clone(),
1206 primary_repository: bundle.primary_repo.clone(),
1207 },
1208 canonical_session: canonical_session_from_materialized(
1209 &hel::hel_state::MaterializedSession::empty(session.id.clone()),
1210 )?,
1211 native_artifacts: Vec::new(),
1212 repositories: snapshots,
1213 },
1214 )?;
1215
1216 let remote_archive = format!("{worker_root}/local-seed.hel.zip");
1217 let remote_spec = format!("{worker_root}/local-seed.json");
1218 let target_path = |path: &str| match locator {
1219 hel_targets::TargetLocator::AwsEc2 { .. } | hel_targets::TargetLocator::SshBare { .. } => {
1220 PathBuf::from(format!("~/{path}"))
1221 }
1222 _ => PathBuf::from(path),
1223 };
1224 let spec = RepositoryRestoreSpec {
1225 archive_path: target_path(&remote_archive),
1226 workspace_root: target_path(workspace_root),
1227 };
1228 let local_spec = staging.join(format!("{}.json", session.id));
1229 hel::hel_config::atomic_write(&local_spec, &serde_json::to_vec_pretty(&spec)?)?;
1230 upload_checkpoint_spec(
1231 executor,
1232 locator,
1233 &session.id,
1234 &archive_path,
1235 &remote_archive,
1236 )?;
1237 upload_checkpoint_spec(executor, locator, &session.id, &local_spec, &remote_spec)?;
1238 execute_checked(
1239 executor,
1240 hel_targets::command_on_locator(
1241 locator,
1242 &session.id,
1243 vec![
1244 format!("{worker_root}/hel"),
1245 "worker".into(),
1246 "restore-repositories".into(),
1247 "--spec".into(),
1248 remote_spec,
1249 ],
1250 "restore local repository bootstrap",
1251 )?,
1252 )?;
1253 Ok(())
1254}
1255
1256#[derive(Debug, Clone)]
1258struct BrokerFiles {
1259 spec: PathBuf,
1260 ready: PathBuf,
1261 pid: PathBuf,
1262 log: PathBuf,
1263}
1264
1265impl BrokerFiles {
1266 fn in_directory(directory: &Path, session_id: &str) -> Self {
1267 Self {
1268 spec: directory.join(format!("{session_id}.json")),
1269 ready: directory.join(format!("{session_id}.ready")),
1270 pid: directory.join(format!("{session_id}.pid")),
1271 log: directory.join(format!("{session_id}.log")),
1272 }
1273 }
1274}
1275
1276const BROKER_READY_TIMEOUT: Duration = Duration::from_secs(10);
1278
1279const BROKER_RESTART_ATTEMPTS: u32 = 5;
1282
1283const BROKER_RESTART_BACKOFF: Duration = Duration::from_millis(250);
1285
1286const BROKER_HEALTHY_RUN: Duration = Duration::from_secs(30);
1289
1290const BROKER_STOP_GRACE: Duration = Duration::from_secs(2);
1293
1294const BROKER_STOP_POLL: Duration = Duration::from_millis(25);
1296
1297fn broker_directory() -> PathBuf {
1298 data_dir().join("git-brokers")
1299}
1300
1301fn ensure_git_broker(
1302 session_id: &str,
1303 locator: &hel_targets::TargetLocator,
1304 repositories: BTreeMap<String, PathBuf>,
1305) -> Result<()> {
1306 let directory = broker_directory();
1307 std::fs::create_dir_all(&directory)?;
1308 let files = BrokerFiles::in_directory(&directory, session_id);
1309 let spec = GitBrokerSpec {
1310 session_id: session_id.to_owned(),
1311 bridge: hel_targets::git_bridge_command(locator, session_id)?,
1312 repositories,
1313 ready_path: files.ready.clone(),
1314 pid_path: files.pid.clone(),
1315 };
1316 if broker_is_alive(&files.pid) {
1317 if broker_serves(&files, &spec) {
1318 return Ok(());
1319 }
1320 bail!(
1321 "a different local Git broker is still active for session {session_id}; close its target before reconnecting"
1322 );
1323 }
1324 spec.write(&files.spec)?;
1325 let child = match start_git_broker(&files) {
1326 Ok(child) => child,
1327 Err(error) if broker_serves(&files, &spec) => {
1331 tracing::debug!(
1332 session_id,
1333 error = format!("{error:#}"),
1334 "reused a concurrently started local Git broker"
1335 );
1336 return Ok(());
1337 }
1338 Err(error) => return Err(error),
1339 };
1340 let session_id = session_id.to_owned();
1343 std::thread::spawn(move || supervise_git_broker(&session_id, &files, child));
1344 Ok(())
1345}
1346
1347fn broker_serves(files: &BrokerFiles, spec: &GitBrokerSpec) -> bool {
1349 broker_is_alive(&files.pid)
1350 && files.ready.exists()
1351 && GitBrokerSpec::read(&files.spec).is_ok_and(|existing| &existing == spec)
1352}
1353
1354fn start_git_broker(files: &BrokerFiles) -> Result<std::process::Child> {
1356 if !broker_is_alive(&files.pid)
1360 && let Err(error) = std::fs::remove_file(&files.ready)
1361 && error.kind() != std::io::ErrorKind::NotFound
1362 {
1363 tracing::warn!(
1364 path = %files.ready.display(),
1365 %error,
1366 "could not remove stale Git broker ready marker"
1367 );
1368 }
1369 let log = std::fs::OpenOptions::new()
1370 .create(true)
1371 .append(true)
1372 .open(&files.log)
1373 .with_context(|| format!("open Git broker log {}", files.log.display()))?;
1374 let stderr = log.try_clone()?;
1375 let executable = std::env::current_exe().context("locate Hel controller executable")?;
1376 let mut command = Command::new(executable);
1377 command
1378 .args(["broker", "--spec"])
1379 .arg(&files.spec)
1380 .stdin(Stdio::null())
1381 .stdout(log)
1382 .stderr(stderr);
1383 #[cfg(unix)]
1384 {
1385 use std::os::unix::process::CommandExt;
1386 command.process_group(0);
1387 }
1388 let mut child = command.spawn().context("start local Git broker")?;
1389 let deadline = Instant::now() + BROKER_READY_TIMEOUT;
1390 loop {
1391 if files.ready.exists() && broker_is_alive(&files.pid) {
1392 return Ok(child);
1393 }
1394 if let Some(status) = child.try_wait().context("poll local Git broker")? {
1395 bail!(
1396 "local Git broker exited with {status}; see {}",
1397 files.log.display()
1398 );
1399 }
1400 if Instant::now() >= deadline {
1401 if let Err(error) = child.kill() {
1403 tracing::warn!(%error, "could not terminate timed-out Git broker");
1404 }
1405 if let Err(error) = child.wait() {
1406 tracing::warn!(%error, "could not reap timed-out Git broker");
1407 }
1408 bail!(
1409 "timed out starting local Git broker; see {}",
1410 files.log.display()
1411 );
1412 }
1413 std::thread::sleep(Duration::from_millis(50));
1414 }
1415}
1416
1417pub(super) fn retire_git_broker(session_id: &str) -> Result<()> {
1425 retire_broker_files(&BrokerFiles::in_directory(&broker_directory(), session_id))
1426}
1427
1428fn retire_broker_files(files: &BrokerFiles) -> Result<()> {
1438 remove_broker_file(&files.spec)?;
1439 stop_running_broker(&files.pid)?;
1440 remove_broker_file(&files.ready)?;
1441 remove_broker_file(&files.pid)?;
1442 Ok(())
1445}
1446
1447fn broker_needs_restart(files: &BrokerFiles) -> bool {
1454 files.spec.exists() && !broker_is_alive(&files.pid)
1455}
1456
1457fn remove_broker_file(path: &Path) -> Result<()> {
1458 match std::fs::remove_file(path) {
1459 Ok(()) => Ok(()),
1460 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
1461 Err(error) => {
1462 Err(error).with_context(|| format!("remove Git broker file {}", path.display()))
1463 }
1464 }
1465}
1466
1467fn stop_running_broker(pid_path: &Path) -> Result<()> {
1474 for escalate in [false, true] {
1475 let deadline = Instant::now() + BROKER_STOP_GRACE;
1476 loop {
1477 if !broker_is_alive(pid_path) {
1478 return Ok(());
1479 }
1480 if let Some(pid) = running_broker_pid(pid_path) {
1481 stop_broker_process_group(pid, escalate);
1482 }
1483 if Instant::now() >= deadline {
1484 break;
1485 }
1486 std::thread::sleep(BROKER_STOP_POLL);
1487 }
1488 }
1489 bail!(
1490 "the local Git broker holding {} did not stop",
1491 pid_path.display()
1492 )
1493}
1494
1495#[cfg(unix)]
1499fn stop_broker_process_group(pid: i32, escalate: bool) {
1500 hel::hel_subprocess::terminate_process_group(
1501 pid,
1502 if escalate {
1503 libc::SIGKILL
1504 } else {
1505 libc::SIGTERM
1506 },
1507 );
1508}
1509
1510#[cfg(not(unix))]
1511fn stop_broker_process_group(_pid: i32, _escalate: bool) {}
1512
1513fn supervise_git_broker(session_id: &str, files: &BrokerFiles, child: std::process::Child) {
1516 let mut started = Some(child);
1517 let outcome = supervise_broker_restarts(
1518 BROKER_RESTART_ATTEMPTS,
1519 || broker_needs_restart(files),
1520 || {
1521 let running = Instant::now();
1522 let mut child = match started.take() {
1523 Some(child) => child,
1524 None => start_git_broker(files)?,
1525 };
1526 let status = child.wait().context("wait for the local Git broker")?;
1527 Ok((running.elapsed(), format!("{status}")))
1528 },
1529 std::thread::sleep,
1530 );
1531 let Err(error) = outcome else {
1532 return;
1533 };
1534 tracing::error!(
1535 session_id,
1536 error = format!("{error:#}"),
1537 "the session's local Git origin is no longer served"
1538 );
1539 use std::io::Write as _;
1542 if let Ok(mut log) = std::fs::OpenOptions::new()
1543 .create(true)
1544 .append(true)
1545 .open(&files.log)
1546 {
1547 let _ = writeln!(log, "[hel {}] local Git origin lost: {error:#}", now());
1548 }
1549}
1550
1551fn supervise_broker_restarts(
1554 attempts: u32,
1555 mut needs_restart: impl FnMut() -> bool,
1556 mut run: impl FnMut() -> Result<(Duration, String)>,
1557 mut back_off: impl FnMut(Duration),
1558) -> Result<()> {
1559 let mut consecutive = 0;
1560 loop {
1561 let failure = match run() {
1562 Ok((ran_for, status)) => {
1563 if ran_for >= BROKER_HEALTHY_RUN {
1564 consecutive = 0;
1565 }
1566 anyhow::anyhow!("the local Git broker exited with {status}")
1567 }
1568 Err(error) => error,
1569 };
1570 if !needs_restart() {
1571 return Ok(());
1572 }
1573 consecutive += 1;
1574 if consecutive > attempts {
1575 return Err(failure.context(format!(
1576 "the local Git broker stopped {consecutive} times in a row and was not restarted again"
1577 )));
1578 }
1579 back_off(BROKER_RESTART_BACKOFF * consecutive);
1580 }
1581}
1582
1583pub(super) fn enforce_overlay_capable_mounts(
1594 target: &hel_targets::TargetTemplate,
1595 mounts: &mut [hel_targets::AdditionalMount],
1596 executor: &impl CommandExecutor,
1597) -> Vec<String> {
1598 let ssh = match target {
1599 hel_targets::TargetTemplate::LocalPodman(_)
1600 | hel_targets::TargetTemplate::LocalDocker(_) => None,
1601 hel_targets::TargetTemplate::SshPodman { ssh, .. }
1602 | hel_targets::TargetTemplate::SshDocker { ssh, .. } => Some(ssh),
1603 _ => return Vec::new(),
1604 };
1605 let overlaid = mounts
1606 .iter()
1607 .filter(|mount| !mount.read_only)
1608 .map(|mount| mount.source.clone())
1609 .collect::<Vec<_>>();
1610 if overlaid.is_empty() {
1611 return Vec::new();
1612 }
1613 let filesystems = match hel_targets::probe_filesystem_types(ssh, &overlaid, executor) {
1614 Ok(filesystems) => filesystems,
1615 Err(error) => {
1616 tracing::warn!(
1617 error = format!("{error:#}"),
1618 "could not probe attached-directory filesystems; preserving overlay mounts"
1619 );
1620 return vec![format!(
1621 "Could not read the filesystem under the attached directories, so they keep the \
1622 copy-on-write overlay: {error:#}"
1623 )];
1624 }
1625 };
1626 let mut notices = Vec::new();
1627 for (mount, filesystem) in mounts
1628 .iter_mut()
1629 .filter(|mount| !mount.read_only)
1630 .zip(filesystems)
1631 {
1632 let Some(reason) = hel_targets::overlay_unsupported_filesystem(&filesystem) else {
1633 continue;
1634 };
1635 mount.read_only = true;
1636 notices.push(format!(
1637 "Mounted {} read-only: the overlay is unreliable on {filesystem} ({reason}).",
1638 mount.source.display()
1639 ));
1640 }
1641 notices
1642}
1643
1644pub(super) struct StagedExecutor<'a, E: CommandExecutor> {
1648 inner: &'a E,
1649 stage: ProvisionStage,
1650 _guard: ProvisionStageGuard<'a, E>,
1651}
1652
1653impl<'a, E: CommandExecutor> StagedExecutor<'a, E> {
1654 pub(crate) fn new(inner: &'a E, stage: ProvisionStage) -> Self {
1655 Self {
1656 inner,
1657 stage,
1658 _guard: ProvisionStageGuard::new(inner, stage),
1659 }
1660 }
1661
1662 fn staged(&self, command: &CommandSpec) -> CommandSpec {
1663 if command.stage.is_some() {
1664 return command.clone();
1665 }
1666 command.clone().stage(self.stage)
1667 }
1668}
1669
1670impl<E: CommandExecutor> CommandExecutor for StagedExecutor<'_, E> {
1671 fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
1672 self.inner.execute(&self.staged(command))
1673 }
1674
1675 fn cancellation_requested(&self) -> bool {
1676 self.inner.cancellation_requested()
1677 }
1678
1679 fn stage_started(&self, stage: ProvisionStage) {
1680 self.inner.stage_started(stage);
1681 }
1682
1683 fn stage_finished(&self, stage: ProvisionStage) {
1684 self.inner.stage_finished(stage);
1685 }
1686
1687 fn notify_notice(&self, notice: &str) {
1688 self.inner.notify_notice(notice);
1689 }
1690
1691 fn execute_with_stdin(
1692 &self,
1693 command: &CommandSpec,
1694 input: &mut (dyn std::io::Read + Send),
1695 ) -> Result<CommandOutput> {
1696 self.inner.execute_with_stdin(&self.staged(command), input)
1697 }
1698}
1699
1700fn execute_checked_with_stdin(
1701 executor: &impl CommandExecutor,
1702 command: &CommandSpec,
1703 input: &mut (dyn std::io::Read + Send),
1704) -> Result<CommandOutput> {
1705 let output = executor.execute_with_stdin(command, input)?;
1706 if output.status != 0 {
1707 bail!(
1708 "{} failed with status {}: {}",
1709 command.purpose,
1710 output.status,
1711 String::from_utf8_lossy(&output.stderr)
1712 );
1713 }
1714 Ok(output)
1715}
1716
1717pub(super) fn install_inherited_git_settings(
1718 executor: &impl CommandExecutor,
1719 locator: &hel_targets::TargetLocator,
1720 session_id: &str,
1721) -> Result<()> {
1722 let settings = if inherits_controller_git_settings(locator) {
1723 controller_git_settings()?
1724 } else {
1725 BTreeMap::new()
1726 };
1727 for command in inherited_git_setting_commands(locator, session_id, settings)? {
1728 execute_checked(executor, command)?;
1729 }
1730 Ok(())
1731}
1732
1733fn inherits_controller_git_settings(locator: &hel_targets::TargetLocator) -> bool {
1734 !matches!(
1735 locator,
1736 hel_targets::TargetLocator::LocalBare { .. } | hel_targets::TargetLocator::SshBare { .. }
1737 )
1738}
1739
1740fn inherited_git_setting_commands(
1741 locator: &hel_targets::TargetLocator,
1742 session_id: &str,
1743 settings: BTreeMap<String, String>,
1744) -> Result<Vec<CommandSpec>> {
1745 if matches!(locator, hel_targets::TargetLocator::SshBare { .. }) {
1746 return Ok(Vec::new());
1747 }
1748 settings
1749 .into_iter()
1750 .map(|(key, value)| {
1751 hel_targets::command_on_locator(
1752 locator,
1753 session_id,
1754 vec![
1755 "git".into(),
1756 "config".into(),
1757 "--global".into(),
1758 "--replace-all".into(),
1759 "--".into(),
1760 key.clone(),
1761 value,
1762 ],
1763 format!("inherit Git setting {key}"),
1764 )
1765 })
1766 .collect()
1767}
1768
1769fn controller_git_settings() -> Result<BTreeMap<String, String>> {
1770 let output = match Command::new("git")
1771 .args(["config", "--global", "--includes", "--null", "--list"])
1772 .stdin(Stdio::null())
1773 .output()
1774 {
1775 Ok(output) => output,
1776 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(BTreeMap::new()),
1777 Err(error) => return Err(error).context("read controller Git configuration"),
1778 };
1779 if !output.status.success() {
1780 bail!(
1781 "read controller Git configuration failed with status {}: {}",
1782 output.status,
1783 String::from_utf8_lossy(&output.stderr).trim()
1784 );
1785 }
1786 parse_inherited_git_settings(&output.stdout)
1787}
1788
1789fn parse_inherited_git_settings(output: &[u8]) -> Result<BTreeMap<String, String>> {
1790 let mut settings = BTreeMap::new();
1791 for entry in output
1792 .split(|byte| *byte == 0)
1793 .filter(|entry| !entry.is_empty())
1794 {
1795 let entry = std::str::from_utf8(entry).context("decode controller Git configuration")?;
1796 let (key, value) = entry
1797 .split_once('\n')
1798 .with_context(|| format!("controller Git returned malformed entry {entry:?}"))?;
1799 let key = key.to_ascii_lowercase();
1800 if INHERITED_GIT_SETTINGS.contains(&key.as_str()) {
1801 settings.insert(key, value.to_owned());
1802 }
1803 }
1804 Ok(settings)
1805}
1806
1807#[cfg(test)]
1808mod tests {
1809 use std::collections::BTreeMap;
1810
1811 use std::sync::Mutex;
1812
1813 use hel::hel_config::{
1814 ContainerTemplate as ConfigContainer, HarnessKind, HarnessProfile, HelConfig,
1815 ProjectRepository, SshConnection,
1816 };
1817 use hel::hel_state::{HelState, SessionRecord, SessionState, TargetLocator};
1818 use hel::hel_targets::{
1819 self, AdditionalMount, ContainerTemplate, ProjectBundleSpec, SshTarget,
1820 };
1821
1822 use crate::hel_controller::SessionLaunchOptions;
1823
1824 use super::*;
1825
1826 struct ProbeExecutor {
1829 answer: std::result::Result<&'static str, &'static str>,
1830 notices: Mutex<Vec<String>>,
1831 }
1832
1833 impl ProbeExecutor {
1834 fn answering(answer: &'static str) -> Self {
1835 Self {
1836 answer: Ok(answer),
1837 notices: Mutex::new(Vec::new()),
1838 }
1839 }
1840
1841 fn failing(stderr: &'static str) -> Self {
1842 Self {
1843 answer: Err(stderr),
1844 notices: Mutex::new(Vec::new()),
1845 }
1846 }
1847 }
1848
1849 impl CommandExecutor for ProbeExecutor {
1850 fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
1851 assert_eq!(command.program, "stat", "only the probe may run here");
1852 Ok(match self.answer {
1853 Ok(filesystem) => CommandOutput {
1854 status: 0,
1855 stdout: format!("{filesystem}\n").into_bytes(),
1856 stderr: Vec::new(),
1857 },
1858 Err(stderr) => CommandOutput {
1859 status: 1,
1860 stdout: Vec::new(),
1861 stderr: stderr.as_bytes().to_vec(),
1862 },
1863 })
1864 }
1865
1866 fn notify_notice(&self, notice: &str) {
1867 self.notices.lock().unwrap().push(notice.to_owned());
1868 }
1869 }
1870
1871 fn podman_target() -> hel_targets::TargetTemplate {
1872 hel_targets::TargetTemplate::LocalPodman(ContainerTemplate {
1873 image: "ubuntu:24.04".into(),
1874 pull_policy: Default::default(),
1875 extra_run_args: Vec::new(),
1876 workspace_storage: Default::default(),
1877 })
1878 }
1879
1880 fn probe_bundle() -> ProjectBundleSpec {
1881 ProjectBundleSpec {
1882 primary: "app".into(),
1883 repositories: vec![hel::hel_targets::RepositorySpec {
1884 url: Some("https://github.com/example/app.git".into()),
1885 destination: "app".into(),
1886 git_ref: None,
1887 reference: None,
1888 }],
1889 }
1890 }
1891
1892 fn ssh_docker_registration_config() -> HelConfig {
1893 let mut config = HelConfig::default();
1894 config.profiles.insert(
1895 "codex".into(),
1896 HarnessProfile {
1897 kind: HarnessKind::Codex,
1898 home: PathBuf::from("/home/dev/.codex"),
1899 environment: BTreeMap::new(),
1900 context_window_bytes: None,
1901 },
1902 );
1903 config.bundles.insert(
1904 "project".into(),
1905 ProjectBundle {
1906 primary_repo: "project".into(),
1907 repositories: vec![ProjectRepository {
1908 id: "project".into(),
1909 github: Some("owner/project".into()),
1910 local: None,
1911 destination: PathBuf::from("project"),
1912 git_ref: None,
1913 }],
1914 },
1915 );
1916 config.targets.insert(
1917 "docker".into(),
1918 TargetTemplate::SshDocker {
1919 ssh: SshConnection {
1920 host: "builder".into(),
1921 user: Some("agent".into()),
1922 identity_file: None,
1923 extra_args: Vec::new(),
1924 },
1925 container: ConfigContainer {
1926 image: "failimage:never".into(),
1927 pull_policy: Default::default(),
1928 platform: None,
1929 cpus: None,
1930 memory: None,
1931 environment: BTreeMap::new(),
1932 workspace_storage: Default::default(),
1933 },
1934 },
1935 );
1936 config
1937 }
1938
1939 #[test]
1940 fn a_source_that_cannot_overlay_is_mounted_read_only_and_reported() {
1941 let executor = ProbeExecutor::answering("nfs");
1942 let mut mounts = vec![AdditionalMount {
1943 source: PathBuf::from("/nfs/share"),
1944 destination: PathBuf::from("/mnt/share"),
1945 read_only: false,
1946 }];
1947
1948 let notices = enforce_overlay_capable_mounts(&podman_target(), &mut mounts, &executor);
1949
1950 assert!(mounts[0].read_only);
1951 assert_eq!(notices.len(), 1);
1952 assert!(
1953 notices[0]
1954 .contains("Mounted /nfs/share read-only: the overlay is unreliable on nfs (network filesystem)"),
1955 "{notices:?}"
1956 );
1957 let plan = hel_targets::provision_plan(
1958 &podman_target(),
1959 "0123456789abcdef0123456789abcdef",
1960 &probe_bundle(),
1961 &mounts,
1962 )
1963 .unwrap();
1964 assert!(
1965 plan.commands[0]
1966 .args
1967 .windows(2)
1968 .any(|args| args == ["--volume", "/nfs/share:/mnt/share:ro"]),
1969 "{:?}",
1970 plan.commands[0].args
1971 );
1972 }
1973
1974 #[test]
1975 fn a_probe_that_cannot_answer_keeps_the_overlay_and_says_so() {
1976 let executor = ProbeExecutor::failing("stat: cannot read file system information");
1977 let mut mounts = vec![AdditionalMount {
1978 source: PathBuf::from("/host/cache"),
1979 destination: PathBuf::from("/mnt/cache"),
1980 read_only: false,
1981 }];
1982
1983 let notices = enforce_overlay_capable_mounts(&podman_target(), &mut mounts, &executor);
1984
1985 assert!(!mounts[0].read_only);
1986 assert_eq!(notices.len(), 1);
1987 assert!(
1988 notices[0].contains("keep the copy-on-write overlay")
1989 && notices[0].contains("cannot read file system information"),
1990 "{notices:?}"
1991 );
1992 let plan = hel_targets::provision_plan(
1993 &podman_target(),
1994 "0123456789abcdef0123456789abcdef",
1995 &probe_bundle(),
1996 &mounts,
1997 )
1998 .unwrap();
1999 assert!(
2000 plan.commands[0]
2001 .args
2002 .windows(2)
2003 .any(|args| args == ["--volume", "/host/cache:/mnt/cache:O"]),
2004 "{:?}",
2005 plan.commands[0].args
2006 );
2007 }
2008
2009 #[test]
2010 fn engines_without_an_overlay_to_lose_are_never_probed() {
2011 struct UnusedExecutor;
2012
2013 impl CommandExecutor for UnusedExecutor {
2014 fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
2015 panic!("this target must not probe: {}", command.program)
2016 }
2017 }
2018
2019 let mut mounts = vec![AdditionalMount {
2020 source: PathBuf::from("/host/cache"),
2021 destination: PathBuf::from("/mnt/cache"),
2022 read_only: false,
2023 }];
2024 for target in [
2025 hel_targets::TargetTemplate::AppleContainer(ContainerTemplate {
2026 image: "ubuntu:24.04".into(),
2027 pull_policy: Default::default(),
2028 extra_run_args: Vec::new(),
2029 workspace_storage: Default::default(),
2030 }),
2031 hel_targets::TargetTemplate::AwsEc2(hel_targets::AwsTemplate {
2032 profile: "default".into(),
2033 region: "us-east-1".into(),
2034 launch_template: "lt-0123456789abcdef0".into(),
2035 launch_template_version: None,
2036 instance_type: None,
2037 ssh: SshTarget {
2038 destination: "ubuntu@example.test".into(),
2039 ssh_args: Vec::new(),
2040 },
2041 }),
2042 ] {
2043 assert!(
2044 enforce_overlay_capable_mounts(&target, &mut mounts, &UnusedExecutor).is_empty()
2045 );
2046 assert!(!mounts[0].read_only);
2047 }
2048 }
2049
2050 #[test]
2053 fn mounts_already_read_only_are_not_probed() {
2054 struct UnusedExecutor;
2055
2056 impl CommandExecutor for UnusedExecutor {
2057 fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
2058 panic!("a read-only mount must not probe: {}", command.program)
2059 }
2060 }
2061
2062 let mut mounts = vec![AdditionalMount {
2063 source: PathBuf::from("/host/cache"),
2064 destination: PathBuf::from("/mnt/cache"),
2065 read_only: true,
2066 }];
2067
2068 assert!(
2069 enforce_overlay_capable_mounts(&podman_target(), &mut mounts, &UnusedExecutor)
2070 .is_empty()
2071 );
2072 }
2073
2074 #[test]
2075 fn failed_new_session_provisioning_discards_provisional_record() {
2076 let session_id = "0123456789abcdef0123456789abcdef";
2077 let record = SessionRecord {
2078 workspace_id: hel::hel_workspace::DEFAULT_WORKSPACE_ID.to_owned(),
2079 archived: false,
2080 container_cpus: None,
2081 container_memory: None,
2082 id: session_id.into(),
2083 title: "new session".into(),
2084 harness_kind: hel::hel_config::HarnessKind::Codex,
2085 last_profile: "codex".into(),
2086 bundle_id: "project".into(),
2087 project_directory: None,
2088 managed_worktree: None,
2089 target_template_id: "podman".into(),
2090 resource_allocation: None,
2091 additional_mounts: Vec::new(),
2092 state: SessionState::Provisioning,
2093 target: None,
2094 native_session_id: None,
2095 acp_session_title: None,
2096 session_title_override: None,
2097 created_at: "2026-08-12T00:00:00Z".into(),
2098 updated_at: "2026-08-12T00:00:00Z".into(),
2099 viewed_through_event_ordinal: 0,
2100 draft_input: String::new(),
2101 last_error: None,
2102 last_checkpoint_error: None,
2103 checkpoint: None,
2104 };
2105 let mut state = HelState::default();
2106 state.sessions.insert(session_id.into(), record);
2107
2108 let result = apply_new_session_provisioning_result(
2109 &mut state,
2110 session_id,
2111 Err(anyhow::anyhow!("container creation failed")),
2112 );
2113
2114 assert!(result.is_err());
2115 assert!(!state.sessions.contains_key(session_id));
2116 }
2117
2118 const SSH_DOCKER_FAILURE_CHILD: &str = "MJ_TEST_SSH_DOCKER_FAILURE_CHILD";
2119
2120 #[test]
2121 fn failed_ssh_docker_preflight_removes_durable_provisioning_record() {
2122 if std::env::var_os(SSH_DOCKER_FAILURE_CHILD).is_none() {
2123 let directory = tempfile::tempdir().unwrap();
2124 let test = "failed_ssh_docker_preflight_removes_durable_provisioning_record";
2125 let mut command = std::process::Command::new(std::env::current_exe().unwrap());
2126 command
2127 .args([
2128 "--exact",
2129 &format!("hel_controller::provisioning::tests::{test}"),
2130 "--nocapture",
2131 ])
2132 .env(SSH_DOCKER_FAILURE_CHILD, "1")
2133 .env("MJ_DATA_DIR", directory.path())
2134 .env("MJ_CONFIG_DIR", directory.path());
2135 let output = hel::hel_subprocess::run_with_input(&mut command, &[]).unwrap();
2136 assert!(
2137 output.status.success(),
2138 "isolated {test} failed\nstdout:\n{}\nstderr:\n{}",
2139 String::from_utf8_lossy(&output.stdout),
2140 String::from_utf8_lossy(&output.stderr)
2141 );
2142 return;
2143 }
2144
2145 let _writer = hel::hel_database::install_isolated_test_writer();
2146 let config = ssh_docker_registration_config();
2147 config.save().unwrap();
2148 let mut controller = Controller {
2149 config,
2150 state: HelState::default(),
2151 };
2152 let session_id = controller
2153 .register_session_with_resources(
2154 "codex",
2155 "project",
2156 "docker",
2157 "failed image",
2158 SessionLaunchOptions {
2159 workspace_id: hel::hel_workspace::DEFAULT_WORKSPACE_ID.to_owned(),
2160 additional_mounts: Vec::new(),
2161 allow_dirty_local: false,
2162 resource_allocation: None,
2163 project_directory: None,
2164 session_title_override: None,
2165 },
2166 )
2167 .unwrap();
2168 assert!(
2169 hel::hel_database::load_state()
2170 .unwrap()
2171 .sessions
2172 .contains_key(&session_id)
2173 );
2174
2175 let executor = RecordingExecutor::failing("check Docker daemon");
2176 let error =
2177 futures::executor::block_on(controller.provision_session_with_failure_disposition(
2178 &session_id,
2179 &executor,
2180 None,
2181 ProvisioningFailureDisposition::Discard,
2182 ))
2183 .unwrap_err();
2184 let reported = format!("{error:#}");
2185 assert!(
2186 reported.contains("remote Docker preflight failed"),
2187 "{reported}"
2188 );
2189 assert!(
2190 executor.commands().iter().any(|argv| {
2191 let command = argv.join(" ");
2192 command.contains("'docker' 'version'")
2193 }),
2194 "the fake preflight did not run: {:?}",
2195 executor.commands()
2196 );
2197 assert!(!controller.state.sessions.contains_key(&session_id));
2198
2199 let reloaded = Controller::load().unwrap();
2200 assert!(
2201 !reloaded.state.sessions.contains_key(&session_id),
2202 "failed SSH Docker launch left a durable provisioning row"
2203 );
2204 }
2205
2206 #[test]
2207 fn failed_new_worker_start_discards_session_only_after_target_cleanup() {
2208 let session_id = "0123456789abcdef0123456789abcdef";
2209 let mut session = SessionRecord {
2210 workspace_id: hel::hel_workspace::DEFAULT_WORKSPACE_ID.to_owned(),
2211 archived: false,
2212 container_cpus: None,
2213 container_memory: None,
2214 id: session_id.into(),
2215 title: "new session".into(),
2216 harness_kind: hel::hel_config::HarnessKind::Kimi,
2217 last_profile: "kimi".into(),
2218 bundle_id: "raw-project".into(),
2219 project_directory: Some("/srv/project".into()),
2220 managed_worktree: None,
2221 target_template_id: "remote".into(),
2222 resource_allocation: None,
2223 additional_mounts: Vec::new(),
2224 state: SessionState::Disconnected,
2225 target: Some(TargetLocator::SshBare {
2226 host: "builder".into(),
2227 workspace: format!(".local/share/hel/workspaces/{session_id}").into(),
2228 worker_id: None,
2229 }),
2230 native_session_id: None,
2231 acp_session_title: None,
2232 session_title_override: None,
2233 created_at: "2026-08-12T00:00:00Z".into(),
2234 updated_at: "2026-08-12T00:00:00Z".into(),
2235 viewed_through_event_ordinal: 0,
2236 draft_input: String::new(),
2237 last_error: None,
2238 last_checkpoint_error: None,
2239 checkpoint: None,
2240 };
2241 let mut cleaned = HelState::default();
2242 cleaned.sessions.insert(session_id.into(), session.clone());
2243
2244 let failure =
2245 apply_failed_new_session_rollback(&mut cleaned, session_id, "ACP startup failed", None);
2246
2247 assert!(!cleaned.sessions.contains_key(session_id));
2248 assert!(
2249 failure
2250 .to_string()
2251 .contains("provisional session discarded")
2252 );
2253
2254 session.state = SessionState::Disconnected;
2255 let mut cleanup_failed = HelState::default();
2256 cleanup_failed.sessions.insert(session_id.into(), session);
2257 let failure = apply_failed_new_session_rollback(
2258 &mut cleanup_failed,
2259 session_id,
2260 "ACP startup failed",
2261 Some("ssh unavailable".into()),
2262 );
2263 let retained = cleanup_failed.sessions.get(session_id).unwrap();
2264 assert_eq!(retained.state, SessionState::Error);
2265 assert!(retained.target.is_some());
2266 assert!(failure.to_string().contains("cleanup"));
2267 }
2268 #[test]
2269 fn launch_failure_is_persisted_separately_from_session_state() {
2270 let directory = tempfile::tempdir().unwrap();
2271 let session_id = "0123456789abcdef0123456789abcdef";
2272 let detail = format!(
2273 "specific startup cause\n{}\nstderr tail survives",
2274 "x".repeat(MAX_LAUNCH_DIAGNOSTIC_BYTES)
2275 );
2276
2277 let path = persist_launch_failure_to(directory.path(), session_id, &detail).unwrap();
2278 let saved = std::fs::read_to_string(path).unwrap();
2279
2280 assert!(saved.contains("specific startup cause"));
2281 assert!(saved.contains("launch diagnostic truncated"));
2282 assert!(saved.contains("stderr tail survives"));
2283 #[cfg(unix)]
2284 {
2285 use std::os::unix::fs::PermissionsExt;
2286 assert_eq!(
2287 std::fs::metadata(directory.path())
2288 .unwrap()
2289 .permissions()
2290 .mode()
2291 & 0o777,
2292 0o700
2293 );
2294 }
2295 }
2296 #[test]
2297 fn inherited_git_settings_allow_only_portable_non_executable_values() {
2298 let settings = parse_inherited_git_settings(
2299 b"user.name\nAgent User\0USER.EMAIL\nagent@example.test\0pull.rebase\ntrue\0alias.deploy\n!ship\0credential.helper\nstore\0core.editor\nvim\0include.path\n/host/config\0user.name\nFinal User\0",
2300 )
2301 .unwrap();
2302
2303 assert_eq!(
2304 settings,
2305 BTreeMap::from([
2306 ("pull.rebase".into(), "true".into()),
2307 ("user.email".into(), "agent@example.test".into()),
2308 ("user.name".into(), "Final User".into()),
2309 ])
2310 );
2311 }
2312 #[test]
2313 fn inherited_git_settings_reject_malformed_or_non_utf8_output() {
2314 assert!(parse_inherited_git_settings(b"user.name\0").is_err());
2315 assert!(parse_inherited_git_settings(b"user.name\n\xff\0").is_err());
2316 }
2317 #[test]
2318 fn inherited_git_settings_target_only_isolated_workers() {
2319 let ssh = SshTarget {
2320 destination: "worker@example.test".into(),
2321 ssh_args: vec!["-p".into(), "2222".into()],
2322 };
2323 let ephemeral = [
2324 hel_targets::TargetLocator::LocalPodman {
2325 container_id: "abcdef012345".into(),
2326 workspace_storage: Default::default(),
2327 },
2328 hel_targets::TargetLocator::AppleContainer {
2329 container_id: "abcdef012346".into(),
2330 },
2331 hel_targets::TargetLocator::AwsEc2 {
2332 profile: "default".into(),
2333 region: "us-east-1".into(),
2334 instance_id: "i-1234567890abcdef0".into(),
2335 ssh: ssh.clone(),
2336 workspace: ".local/share/hel/workspaces/018f9dd2-a3b4-7c8d-9000-123456789abc"
2337 .into(),
2338 },
2339 hel_targets::TargetLocator::SshPodman {
2340 ssh: ssh.clone(),
2341 container_id: "abcdef012347".into(),
2342 workspace_storage: Default::default(),
2343 },
2344 ];
2345 for locator in &ephemeral {
2346 assert!(inherits_controller_git_settings(locator));
2347 let commands = inherited_git_setting_commands(
2348 locator,
2349 "018f9dd2-a3b4-7c8d-9000-123456789abc",
2350 BTreeMap::from([("user.name".into(), "- Agent O'Brien 日本語".into())]),
2351 )
2352 .unwrap();
2353 assert_eq!(commands.len(), 1);
2354 assert!(
2355 commands[0]
2356 .args
2357 .iter()
2358 .any(|argument| argument.contains("user.name"))
2359 );
2360 assert!(
2361 commands[0]
2362 .args
2363 .iter()
2364 .any(|argument| argument.contains("- Agent O'"))
2365 );
2366 }
2367
2368 let persistent = hel_targets::TargetLocator::SshBare {
2369 ssh,
2370 workspace: "/srv/hel/018f9dd2-a3b4-7c8d-9000-123456789abc".into(),
2371 };
2372 let local = hel_targets::TargetLocator::LocalBare {
2373 worker_root: "/var/lib/hel/workers/018f9dd2-a3b4-7c8d-9000-123456789abc".into(),
2374 };
2375 assert!(!inherits_controller_git_settings(&persistent));
2376 assert!(!inherits_controller_git_settings(&local));
2377 assert!(
2378 inherited_git_setting_commands(
2379 &persistent,
2380 "018f9dd2-a3b4-7c8d-9000-123456789abc",
2381 BTreeMap::from([("user.name".into(), "Agent".into())]),
2382 )
2383 .unwrap()
2384 .is_empty()
2385 );
2386 }
2387
2388 #[test]
2389 fn raw_ssh_targets_select_permissions_and_ssh_podman_is_unconstrained() {
2390 let ssh = hel::hel_config::SshConnection {
2391 host: "builder".into(),
2392 user: None,
2393 identity_file: None,
2394 extra_args: Vec::new(),
2395 };
2396 let guardian = TargetTemplate::SshBare {
2397 ssh: ssh.clone(),
2398 permissions: hel::hel_config::PermissionMode::Guardian,
2399 workspace_prefix: ".local/share/hel/workspaces".into(),
2400 };
2401 let podman = TargetTemplate::SshPodman {
2402 ssh: ssh.clone(),
2403 container: hel::hel_config::ContainerTemplate {
2404 image: "example.invalid/agent:latest".into(),
2405 pull_policy: Default::default(),
2406 platform: None,
2407 cpus: None,
2408 memory: None,
2409 environment: BTreeMap::new(),
2410 workspace_storage: Default::default(),
2411 },
2412 };
2413 let yolo = TargetTemplate::SshBare {
2414 ssh,
2415 permissions: hel::hel_config::PermissionMode::Yolo,
2416 workspace_prefix: ".local/share/hel/workspaces".into(),
2417 };
2418
2419 assert_eq!(
2420 TargetTemplate::LocalBare.execution_policy(),
2421 hel::hel_config::ExecutionPolicy::ConfiguredApprovals
2422 );
2423 assert_eq!(
2424 guardian.execution_policy(),
2425 hel::hel_config::ExecutionPolicy::ConfiguredApprovals
2426 );
2427 assert_eq!(
2428 podman.execution_policy(),
2429 hel::hel_config::ExecutionPolicy::Unconstrained
2430 );
2431 assert_eq!(
2432 yolo.execution_policy(),
2433 hel::hel_config::ExecutionPolicy::Unconstrained
2434 );
2435 }
2436 const PROVISIONED_SESSION: &str = "0123456789abcdef0123456789abcdef";
2437
2438 struct RecordingExecutor {
2441 failing_purpose: String,
2442 commands: Mutex<Vec<Vec<String>>>,
2443 }
2444
2445 impl RecordingExecutor {
2446 fn failing(purpose: impl Into<String>) -> Self {
2447 Self {
2448 failing_purpose: purpose.into(),
2449 commands: Mutex::new(Vec::new()),
2450 }
2451 }
2452
2453 fn succeeding() -> Self {
2454 Self::failing(String::new())
2455 }
2456
2457 fn commands(&self) -> Vec<Vec<String>> {
2458 self.commands.lock().unwrap().clone()
2459 }
2460 }
2461
2462 impl CommandExecutor for RecordingExecutor {
2463 fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
2464 let mut argv = vec![command.program.clone()];
2465 argv.extend(command.args.clone());
2466 self.commands.lock().unwrap().push(argv);
2467 Ok(CommandOutput {
2468 status: i32::from(command.purpose == self.failing_purpose),
2469 stdout: Vec::new(),
2470 stderr: b"the step failed".to_vec(),
2471 })
2472 }
2473 }
2474
2475 fn container_targets() -> Vec<hel_targets::TargetTemplate> {
2476 let container = ContainerTemplate {
2477 image: "ubuntu:24.04".into(),
2478 pull_policy: Default::default(),
2479 extra_run_args: Vec::new(),
2480 workspace_storage: Default::default(),
2481 };
2482 vec![
2483 hel_targets::TargetTemplate::LocalPodman(container.clone()),
2484 hel_targets::TargetTemplate::AppleContainer(container.clone()),
2485 hel_targets::TargetTemplate::SshPodman {
2486 ssh: SshTarget {
2487 destination: "dev@example.test".into(),
2488 ssh_args: vec!["-o".into(), "BatchMode=yes".into()],
2489 },
2490 container,
2491 },
2492 ]
2493 }
2494
2495 #[test]
2496 fn a_failure_after_the_container_exists_removes_it_and_keeps_the_original_error() {
2497 let name = hel_targets::resource_name(PROVISIONED_SESSION).unwrap();
2498 for target in container_targets() {
2499 let plan =
2500 hel_targets::provision_plan(&target, PROVISIONED_SESSION, &probe_bundle(), &[])
2501 .unwrap();
2502 let executor = RecordingExecutor::failing("clone app");
2503
2504 let error = provision_target(&plan, &target, PROVISIONED_SESSION, &executor, |_| {
2505 unreachable!("locator discovery must not run after a failed plan")
2506 })
2507 .unwrap_err();
2508
2509 let reported = format!("{error:#}");
2510 assert!(reported.contains("clone app failed"), "{reported}");
2511 assert!(reported.contains("cleanup succeeded"), "{reported}");
2512 let removal = executor
2514 .commands()
2515 .into_iter()
2516 .map(|arguments| arguments.join(" ").replace('\'', ""))
2517 .find(|command| command.contains("rm --force") && command.contains(&name))
2518 .expect("cleanup removes the exact provisioned container");
2519 assert!(removal.contains("rm --force"), "{removal}");
2520 assert!(removal.contains(&name), "{removal}");
2521 }
2522 }
2523
2524 #[test]
2525 fn target_creation_returns_repository_setup_without_running_it() {
2526 let target = podman_target();
2527 let plan = hel_targets::provision_plan(&target, PROVISIONED_SESSION, &probe_bundle(), &[])
2528 .unwrap();
2529 let executor = RecordingExecutor::succeeding();
2530
2531 let (_, repositories) =
2532 provision_target_creation(&plan, &target, PROVISIONED_SESSION, &executor, |_| {
2533 Ok(TargetLocator::LocalPodman {
2534 container_id: hel_targets::resource_name(PROVISIONED_SESSION)?,
2535 workspace_storage: Default::default(),
2536 })
2537 })
2538 .unwrap();
2539
2540 assert_eq!(executor.commands().len(), 1, "only podman run may execute");
2541 assert!(
2542 repositories
2543 .commands
2544 .iter()
2545 .any(|command| command.purpose == "clone app")
2546 );
2547 }
2548
2549 #[test]
2550 fn a_target_whose_creation_failed_is_never_torn_down() {
2551 for target in container_targets() {
2552 let plan =
2553 hel_targets::provision_plan(&target, PROVISIONED_SESSION, &probe_bundle(), &[])
2554 .unwrap();
2555 let creation = plan.split_at_target_creation().unwrap().0;
2556 let executor =
2557 RecordingExecutor::failing(creation.commands.last().unwrap().purpose.clone());
2558
2559 let error = provision_target(&plan, &target, PROVISIONED_SESSION, &executor, |_| {
2560 unreachable!("locator discovery must not run after a failed plan")
2561 })
2562 .unwrap_err();
2563
2564 let reported = format!("{error:#}");
2565 assert!(!reported.contains("cleanup"), "{reported}");
2566 assert!(
2567 !executor
2568 .commands()
2569 .iter()
2570 .any(|argv| argv.join(" ").contains("rm --force")),
2571 "{:?}",
2572 executor.commands()
2573 );
2574 }
2575 }
2576
2577 #[test]
2578 fn a_target_whose_locator_cannot_be_discovered_is_removed_again() {
2579 let target = podman_target();
2580 let plan = hel_targets::provision_plan(&target, PROVISIONED_SESSION, &probe_bundle(), &[])
2581 .unwrap();
2582 let executor = RecordingExecutor::succeeding();
2583
2584 let error = provision_target(&plan, &target, PROVISIONED_SESSION, &executor, |_| {
2585 bail!("the container never reported an address")
2586 })
2587 .unwrap_err();
2588
2589 let reported = format!("{error:#}");
2590 assert!(reported.contains("never reported an address"), "{reported}");
2591 assert!(reported.contains("cleanup succeeded"), "{reported}");
2592 let removal = executor
2593 .commands()
2594 .into_iter()
2595 .map(|arguments| arguments.join(" "))
2596 .find(|command| command.contains("podman rm --force --ignore"))
2597 .expect("cleanup removes the provisioned Podman container");
2598 assert!(removal.contains("podman rm --force --ignore"), "{removal}");
2599 }
2600
2601 #[test]
2604 fn a_bare_project_failure_removes_nothing() {
2605 let target = hel_targets::TargetTemplate::LocalBare;
2606 let plan =
2607 hel_targets::provision_bare_project_plan(&target, PROVISIONED_SESSION, "/srv/project")
2608 .unwrap();
2609 let executor = RecordingExecutor::succeeding();
2610
2611 let error = provision_target(&plan, &target, PROVISIONED_SESSION, &executor, |_| {
2612 bail!("the worker root was unreadable")
2613 })
2614 .unwrap_err();
2615
2616 assert!(!format!("{error:#}").contains("cleanup"));
2617 assert!(executor.commands().is_empty());
2618 }
2619
2620 #[test]
2621 fn a_broker_that_keeps_stopping_is_restarted_a_bounded_number_of_times() {
2622 let mut runs = 0;
2623 let mut waits = Vec::new();
2624
2625 let error = supervise_broker_restarts(
2626 3,
2627 || true,
2628 || {
2629 runs += 1;
2630 Ok((Duration::from_millis(1), "signal: 9".into()))
2631 },
2632 |delay| waits.push(delay),
2633 )
2634 .unwrap_err();
2635
2636 assert_eq!(runs, 4);
2638 assert_eq!(waits.len(), 3);
2639 assert!(waits[0] < waits[2], "{waits:?}");
2640 assert!(
2641 format!("{error:#}").contains("stopped 4 times in a row"),
2642 "{error:#}"
2643 );
2644 }
2645
2646 #[test]
2647 fn a_broker_another_controller_took_over_is_left_alone() {
2648 let mut runs = 0;
2649
2650 supervise_broker_restarts(
2651 3,
2652 || false,
2653 || {
2654 runs += 1;
2655 Ok((Duration::from_millis(1), "exit status: 0".into()))
2656 },
2657 |_| unreachable!("a broker that needs no restart must not be waited on"),
2658 )
2659 .unwrap();
2660
2661 assert_eq!(runs, 1);
2662 }
2663
2664 #[test]
2667 fn a_broker_that_ran_healthily_earns_a_fresh_restart_budget() {
2668 let mut runs = 0;
2669
2670 let error = supervise_broker_restarts(
2671 2,
2672 || true,
2673 || {
2674 runs += 1;
2675 Ok(if runs <= 4 {
2676 (BROKER_HEALTHY_RUN, "signal: 9".into())
2677 } else {
2678 (Duration::from_millis(1), "signal: 9".into())
2679 })
2680 },
2681 |_| (),
2682 )
2683 .unwrap_err();
2684
2685 assert_eq!(runs, 6);
2686 assert!(format!("{error:#}").contains("stopped 3 times in a row"));
2687 }
2688
2689 #[cfg(unix)]
2694 const BROKER_STAND_IN_PID_PATH: &str = "MJ_TEST_BROKER_STAND_IN_PID_PATH";
2695
2696 fn retirable_broker_files(directory: &Path) -> BrokerFiles {
2697 let files = BrokerFiles::in_directory(directory, PROVISIONED_SESSION);
2698 GitBrokerSpec {
2699 session_id: PROVISIONED_SESSION.into(),
2700 bridge: CommandSpec::new("true", Vec::<String>::new()),
2701 repositories: BTreeMap::new(),
2702 ready_path: files.ready.clone(),
2703 pid_path: files.pid.clone(),
2704 }
2705 .write(&files.spec)
2706 .unwrap();
2707 std::fs::write(&files.ready, "ready\n").unwrap();
2708 std::fs::write(&files.log, "broker log\n").unwrap();
2709 files
2710 }
2711
2712 #[cfg(unix)]
2716 #[test]
2717 fn retiring_a_session_stops_its_running_broker_and_reports_nothing() {
2718 if let Some(pid_path) = std::env::var_os(BROKER_STAND_IN_PID_PATH) {
2719 let _slot = hel::hel_git_proxy::claim_broker_pid_file(Path::new(&pid_path)).unwrap();
2720 std::thread::sleep(Duration::from_secs(60));
2723 return;
2724 }
2725
2726 let directory = tempfile::tempdir().unwrap();
2727 let files = retirable_broker_files(directory.path());
2728 let test_name = format!(
2729 "{}::retiring_a_session_stops_its_running_broker_and_reports_nothing",
2730 module_path!()
2731 .strip_prefix("mj_controller::")
2732 .unwrap_or(module_path!())
2733 );
2734 let mut command = Command::new(std::env::current_exe().unwrap());
2735 command
2736 .args(["--exact", &test_name, "--nocapture"])
2737 .env(BROKER_STAND_IN_PID_PATH, &files.pid)
2738 .stdin(Stdio::null())
2739 .stdout(Stdio::null())
2740 .stderr(Stdio::inherit());
2741 {
2742 use std::os::unix::process::CommandExt;
2743 command.process_group(0);
2746 }
2747 let child = command.spawn().unwrap();
2748 let deadline = Instant::now() + Duration::from_secs(30);
2749 while !broker_is_alive(&files.pid) {
2750 assert!(
2751 Instant::now() < deadline,
2752 "the stand-in broker never claimed its slot"
2753 );
2754 std::thread::sleep(Duration::from_millis(10));
2755 }
2756
2757 let (finished, supervised) = std::sync::mpsc::channel();
2758 let supervisor = {
2759 let files = files.clone();
2760 std::thread::spawn(move || {
2761 supervise_git_broker(PROVISIONED_SESSION, &files, child);
2762 let _ = finished.send(());
2763 })
2764 };
2765
2766 retire_broker_files(&files).unwrap();
2767
2768 supervised
2769 .recv_timeout(Duration::from_secs(30))
2770 .expect("the retired broker's supervisor never finished");
2771 supervisor.join().unwrap();
2772 assert!(!broker_is_alive(&files.pid));
2773 assert!(!files.spec.exists());
2774 assert!(!files.pid.exists());
2775 assert!(!files.ready.exists());
2776 assert_eq!(std::fs::read_to_string(&files.log).unwrap(), "broker log\n");
2777 }
2778
2779 #[test]
2782 fn a_retired_broker_is_never_restarted_where_a_dead_one_is() {
2783 let directory = tempfile::tempdir().unwrap();
2784 let files = retirable_broker_files(directory.path());
2785 std::fs::write(&files.pid, "424242").unwrap();
2787 assert!(broker_needs_restart(&files));
2788
2789 retire_broker_files(&files).unwrap();
2790
2791 assert!(!broker_needs_restart(&files));
2792 let mut runs = 0;
2793 supervise_broker_restarts(
2794 BROKER_RESTART_ATTEMPTS,
2795 || broker_needs_restart(&files),
2796 || {
2797 runs += 1;
2798 Ok((Duration::from_millis(1), "signal: 15".into()))
2799 },
2800 |_| unreachable!("a retired broker must never be waited on for a restart"),
2801 )
2802 .unwrap();
2803
2804 assert_eq!(runs, 1);
2806 assert!(!files.spec.exists());
2807 assert!(!files.pid.exists());
2808 assert!(!files.ready.exists());
2809 assert_eq!(std::fs::read_to_string(&files.log).unwrap(), "broker log\n");
2810 }
2811
2812 #[test]
2815 fn retiring_a_session_that_never_had_a_broker_creates_nothing() {
2816 let directory = tempfile::tempdir().unwrap();
2817 let brokers = directory.path().join("git-brokers");
2818
2819 retire_broker_files(&BrokerFiles::in_directory(&brokers, PROVISIONED_SESSION)).unwrap();
2820
2821 assert!(!brokers.exists());
2822 assert!(
2823 std::fs::read_dir(directory.path())
2824 .unwrap()
2825 .next()
2826 .is_none()
2827 );
2828 }
2829
2830 #[test]
2831 fn a_converting_resume_seeds_from_its_own_checkout() {
2832 let repository = ProjectRepository {
2833 id: "project".into(),
2834 github: None,
2835 local: Some(PathBuf::from("/home/dev/project")),
2836 destination: PathBuf::from("project"),
2837 git_ref: None,
2838 };
2839 let configured = PathBuf::from("/home/dev/project");
2840 let missing = vec![(&repository, &configured)];
2841 let checkout = PathBuf::from("/home/dev/project/.mj/worktrees/session");
2842
2843 assert_eq!(seed_sources(&missing, &LocalBootstrap::Skip), None);
2844 assert_eq!(
2845 seed_sources(&missing, &LocalBootstrap::Seed)
2846 .unwrap()
2847 .into_iter()
2848 .map(|(_, source)| source.clone())
2849 .collect::<Vec<_>>(),
2850 vec![configured.clone()]
2851 );
2852 assert_eq!(
2853 seed_sources(&missing, &LocalBootstrap::SeedFrom(checkout.clone()))
2854 .unwrap()
2855 .into_iter()
2856 .map(|(_, source)| source.clone())
2857 .collect::<Vec<_>>(),
2858 vec![checkout]
2859 );
2860 }
2861}