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 crate::hel_git_proxy::{GitBrokerSpec, broker_is_alive, running_broker_pid};
11use hel::hel_archive::{
12 ArchiveInput, BundleManifest, GitCollectionSpec, GitHistoryMode, SessionManifest, SystemGit,
13 TargetManifest, collect_git_snapshot, write_archive_atomic,
14};
15use hel::hel_checkpoint::RepositoryRestoreSpec;
16use hel::hel_config::{ProjectBundle, TargetTemplate, atomic_write, data_dir};
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 apply_new_session_provisioning_result(&mut self.state, session_id, Err(error))?;
395 unreachable!("an unsuccessful provisioning result returned Ok")
396 }
397 Ok((locator, remainder, bundle)) => {
398 apply_new_session_provisioning_result(&mut self.state, session_id, Ok(locator))?;
399 let session = &self.state.sessions[session_id];
400 let backend = backend_locator(
401 session
402 .target
403 .as_ref()
404 .context("provisioned target disappeared")?,
405 session,
406 &self.config,
407 )?;
408 if matches!(backend, hel_targets::TargetLocator::AwsEc2 { .. }) {
409 hel_targets::provision_on_locator_plan(
410 &backend,
411 session_id,
412 bundle
413 .as_ref()
414 .context("AWS provisioning requires a project bundle")?,
415 )
416 } else {
417 Ok(remainder)
418 }
419 }
420 };
421 let result = match result {
422 Err(error) if failure_disposition == ProvisioningFailureDisposition::Discard => {
423 return Err(self.rollback_failed_new_session(session_id, error, executor)?);
424 }
425 result => result,
426 };
427 if result.is_ok()
428 && let Some(session) = self.state.sessions.get(session_id)
429 && let Some(directory) = session
430 .managed_worktree
431 .as_ref()
432 .map(|worktree| worktree.source_project_directory.clone())
433 .or_else(|| session.project_directory.clone())
434 && let Some(template) = self.config.targets.get(&session.target_template_id)
435 {
436 let host = match template {
437 TargetTemplate::LocalBare => Some("local"),
438 TargetTemplate::SshBare { ssh, .. } => Some(ssh.host.as_str()),
439 _ => None,
440 };
441 if let Some(host) = host {
442 self.state.remember_project_directory(host, &directory);
443 hel::hel_database::remember_project_directory(host, &directory)?;
444 }
445 }
446 self.persist_session_state(session_id)?;
447 result
448 }
449
450 pub fn mark_worker_connected(
451 &mut self,
452 session_id: &str,
453 native_session_id: Option<String>,
454 ) -> Result<()> {
455 let session = self
456 .state
457 .sessions
458 .get(session_id)
459 .with_context(|| format!("unknown session {session_id}"))?;
460 if session.target.is_none() {
461 bail!("session {session_id} has no provisioned target");
462 }
463 let updated_at = now();
464 hel::hel_database::mark_session_worker_connected(
465 session_id,
466 native_session_id.as_deref(),
467 &updated_at,
468 )?;
469 let session = self
470 .state
471 .sessions
472 .get_mut(session_id)
473 .expect("session disappeared after its worker connection was saved");
474 session.state = SessionState::Running;
475 if native_session_id.is_some() {
476 session.native_session_id = native_session_id;
477 }
478 session.updated_at = updated_at;
479 session.last_error = None;
480 Ok(())
481 }
482
483 fn install_worker_payload(
484 &self,
485 session_id: &str,
486 executor: &impl CommandExecutor,
487 ) -> Result<(hel_targets::TargetLocator, String)> {
488 let syncing = &StagedExecutor::new(executor, ProvisionStage::Syncing);
490 let (backend, worker_root) = self.worker_placement(session_id)?;
491 self.prepare_worker_files(session_id, &backend, &worker_root, syncing)?;
492 install_attached_resources(&self.state, session_id, &backend, &worker_root, syncing)?;
493 Ok((backend, worker_root))
494 }
495
496 async fn connect_and_start_worker(
497 &self,
498 session_id: &str,
499 executor: &impl CommandExecutor,
500 backend: &hel_targets::TargetLocator,
501 worker_root: &str,
502 ) -> Result<Option<String>> {
503 {
506 let syncing = &StagedExecutor::new(executor, ProvisionStage::Syncing);
507 install_inherited_git_settings(executor, backend, session_id)?;
508 self.connect_local_repositories(
509 session_id,
510 backend,
511 worker_root,
512 syncing,
513 LocalBootstrap::Seed,
514 )?;
515 }
516 let session = self
517 .state
518 .sessions
519 .get(session_id)
520 .with_context(|| format!("unknown session {session_id}"))?;
521 let profile = self
522 .config
523 .profiles
524 .get(&session.last_profile)
525 .with_context(|| format!("unknown profile {}", session.last_profile))?;
526 let readiness_stage = bridge_readiness_stage(profile);
527 let reconnect = &hel_targets::reconnect_plan(backend, session_id)?.commands[0];
528 let readiness = async {
529 let mut relay = {
530 let _starting = ProvisionStageGuard::new(executor, ProvisionStage::Starting);
531 start_worker(executor, backend, worker_root)?;
532 connect_started_worker(reconnect, session_id, executor, backend, worker_root)
533 .await?
534 };
535 let native_session_id =
536 wait_for_native_session_in_stage(&mut relay, executor, readiness_stage).await?;
537 Ok(Some(native_session_id))
538 }
539 .await;
540 match readiness {
541 Ok(native_session_id) => Ok(native_session_id),
542 Err(error) => Err(worker_probe_diagnosis(
543 executor,
544 backend,
545 worker_root,
546 error,
547 )),
548 }
549 }
550
551 pub(super) fn connect_local_repositories(
555 &self,
556 session_id: &str,
557 backend: &hel_targets::TargetLocator,
558 worker_root: &str,
559 executor: &impl CommandExecutor,
560 bootstrap: LocalBootstrap,
561 ) -> Result<()> {
562 let session = self
563 .state
564 .sessions
565 .get(session_id)
566 .with_context(|| format!("unknown session {session_id}"))?;
567 if session.project_directory.is_some() {
568 return Ok(());
569 }
570 let bundle = self
571 .config
572 .bundles
573 .get(&session.bundle_id)
574 .context("session bundle is missing")?;
575 let local = bundle
576 .repositories
577 .iter()
578 .filter_map(|repository| repository.local.as_ref().map(|path| (repository, path)))
579 .collect::<Vec<_>>();
580 if local.is_empty() {
581 return Ok(());
582 }
583
584 let absolute_worker_root =
585 absolute_target_path(executor, backend, session_id, worker_root)?;
586 let repositories = local
587 .iter()
588 .map(|(repository, path)| Ok((repository.id.clone(), canonical_repository(path)?)))
589 .collect::<Result<BTreeMap<_, _>>>()?;
590 ensure_git_broker(session_id, backend, repositories)?;
591
592 let workspace_root = match backend {
593 hel_targets::TargetLocator::LocalPodman { .. }
594 | hel_targets::TargetLocator::LocalDocker { .. }
595 | hel_targets::TargetLocator::AppleContainer { .. }
596 | hel_targets::TargetLocator::SshPodman { .. } => "/workspace".to_owned(),
597 hel_targets::TargetLocator::AwsEc2 { workspace, .. }
598 | hel_targets::TargetLocator::SshBare { workspace, .. } => workspace.clone(),
599 hel_targets::TargetLocator::LocalBare { worker_root } => worker_root.clone(),
600 };
601 let mut missing = Vec::new();
602 for &(repository, source) in &local {
603 local_branch(source)?;
604 let destination = format!(
605 "{workspace_root}/{}",
606 repository.destination.to_string_lossy()
607 );
608 let origin = local_origin_url(&absolute_worker_root, &repository.id);
609 for (args, purpose) in [
610 (
611 vec![
612 "git".into(),
613 "-C".into(),
614 destination.clone(),
615 "config".into(),
616 "protocol.ext.allow".into(),
617 "always".into(),
618 ],
619 "enable the confined local Git transport",
620 ),
621 (
622 vec![
623 "git".into(),
624 "-C".into(),
625 destination.clone(),
626 "config".into(),
627 "remote.origin.url".into(),
628 origin,
629 ],
630 "configure local Git origin",
631 ),
632 (
633 vec![
634 "git".into(),
635 "-C".into(),
636 destination.clone(),
637 "config".into(),
638 "remote.origin.fetch".into(),
639 "+refs/heads/*:refs/remotes/origin/*".into(),
640 ],
641 "configure local Git fetch refspec",
642 ),
643 ] {
644 execute_checked(
645 executor,
646 hel_targets::command_on_locator(backend, session_id, args, purpose)?,
647 )?;
648 }
649 let has_head = executor.execute(&hel_targets::command_on_locator(
650 backend,
651 session_id,
652 vec![
653 "git".into(),
654 "-C".into(),
655 destination.clone(),
656 "rev-parse".into(),
657 "--verify".into(),
658 "HEAD".into(),
659 ],
660 "inspect local Git bootstrap state",
661 )?)?;
662 if has_head.status != 0 {
663 missing.push((repository, source));
664 }
665 }
666 for (repository, _) in &local {
670 let destination = format!(
671 "{workspace_root}/{}",
672 repository.destination.to_string_lossy()
673 );
674 execute_checked(
675 executor,
676 hel_targets::command_on_locator(
677 backend,
678 session_id,
679 vec![
680 "git".into(),
681 "-C".into(),
682 destination.clone(),
683 "fetch".into(),
684 "origin".into(),
685 ],
686 "fetch local Git origin",
687 )?,
688 )?;
689 }
690 if let Some(sources) = seed_sources(&missing, &bootstrap)
691 && !sources.is_empty()
692 {
693 bootstrap_local_repositories(
694 executor,
695 backend,
696 session,
697 bundle,
698 &workspace_root,
699 worker_root,
700 &sources,
701 )?;
702 }
703 Ok(())
704 }
705}
706
707const MAX_LAUNCH_DIAGNOSTIC_BYTES: usize = 64 * 1024;
708
709const RETAINED_LAUNCH_DIAGNOSTICS: usize = 20;
710
711fn persist_launch_failure(session_id: &str, detail: &str) -> Result<PathBuf> {
712 persist_launch_failure_to(&data_dir().join("diagnostics"), session_id, detail)
713}
714
715fn persist_launch_failure_to(directory: &Path, session_id: &str, detail: &str) -> Result<PathBuf> {
716 hel::hel_config::validate_id("session", session_id)?;
717 std::fs::create_dir_all(directory).with_context(|| {
718 format!(
719 "create launch diagnostics directory {}",
720 directory.display()
721 )
722 })?;
723 #[cfg(unix)]
724 {
725 use std::os::unix::fs::PermissionsExt;
726 std::fs::set_permissions(directory, std::fs::Permissions::from_mode(0o700))?;
727 }
728 let path = directory.join(format!("{session_id}-launch-error.txt"));
729 let detail = bounded_launch_diagnostic(detail);
730 let body = format!(
731 "Hel session launch failure\nsession: {session_id}\nat: {}\n\n{detail}\n",
732 now()
733 );
734 atomic_write(&path, body.as_bytes())?;
735 prune_launch_diagnostics(directory)?;
736 Ok(path)
737}
738
739fn bounded_launch_diagnostic(detail: &str) -> String {
740 if detail.len() <= MAX_LAUNCH_DIAGNOSTIC_BYTES {
741 return detail.to_owned();
742 }
743 let mut head_end = MAX_LAUNCH_DIAGNOSTIC_BYTES / 4;
744 while !detail.is_char_boundary(head_end) {
745 head_end -= 1;
746 }
747 let tail_bytes = MAX_LAUNCH_DIAGNOSTIC_BYTES - head_end;
748 let mut tail_start = detail.len() - tail_bytes;
749 while !detail.is_char_boundary(tail_start) {
750 tail_start += 1;
751 }
752 format!(
753 "{}\n\n[... launch diagnostic truncated ...]\n\n{}",
754 &detail[..head_end],
755 &detail[tail_start..]
756 )
757}
758
759fn prune_launch_diagnostics(directory: &Path) -> Result<()> {
760 let mut diagnostics = Vec::new();
761 for entry in std::fs::read_dir(directory)? {
762 let entry = entry?;
763 if !entry
764 .file_name()
765 .to_str()
766 .is_some_and(|name| name.ends_with("-launch-error.txt"))
767 {
768 continue;
769 }
770 diagnostics.push((entry.metadata()?.modified()?, entry.path()));
771 }
772 diagnostics.sort_by_key(|entry| std::cmp::Reverse(entry.0));
773 for (_, path) in diagnostics.into_iter().skip(RETAINED_LAUNCH_DIAGNOSTICS) {
774 std::fs::remove_file(&path)
775 .with_context(|| format!("prune old launch diagnostic {}", path.display()))?;
776 }
777 Ok(())
778}
779
780fn apply_new_session_provisioning_result(
781 state: &mut HelState,
782 session_id: &str,
783 result: Result<TargetLocator>,
784) -> Result<()> {
785 match result {
786 Ok(locator) => {
787 let record = state.sessions.get_mut(session_id).unwrap();
788 record.target = Some(locator);
789 record.state = SessionState::Disconnected;
792 record.updated_at = now();
793 record.last_error = None;
794 Ok(())
795 }
796 Err(error) => {
797 state.sessions.remove(session_id);
798 Err(error)
799 }
800 }
801}
802
803pub(super) fn apply_failed_new_session_rollback(
804 state: &mut HelState,
805 session_id: &str,
806 original_error: &str,
807 cleanup_error: Option<String>,
808) -> anyhow::Error {
809 match cleanup_error {
810 None => {
811 state.sessions.remove(session_id);
812 anyhow::anyhow!(
813 "{original_error}; partial target removed and provisional session discarded"
814 )
815 }
816 Some(cleanup_error) => {
817 let failure = format!(
818 "{original_error}; cleanup of the failed session target failed: {cleanup_error}"
819 );
820 let record = state.sessions.get_mut(session_id).unwrap();
821 record.state = SessionState::Error;
822 record.updated_at = now();
823 record.last_error = Some(format!("worker bootstrap failed: {failure}"));
824 anyhow::anyhow!(failure)
825 }
826 }
827}
828
829pub(super) fn install_attached_resources(
830 state: &HelState,
831 session_id: &str,
832 backend: &hel_targets::TargetLocator,
833 worker_root: &str,
834 executor: &impl CommandExecutor,
835) -> Result<()> {
836 let hel_targets::TargetLocator::AwsEc2 { .. } = backend else {
837 return Ok(());
838 };
839 let session = state
840 .sessions
841 .get(session_id)
842 .with_context(|| format!("unknown session {session_id}"))?;
843 if session.additional_mounts.is_empty() {
844 return Ok(());
845 }
846 for resource in &session.additional_mounts {
847 let install = hel_targets::command_on_locator(
848 backend,
849 session_id,
850 vec![
851 format!("{worker_root}/hel"),
852 "worker".into(),
853 "install-resource".into(),
854 "--destination".into(),
855 resource.destination.to_string_lossy().into_owned(),
856 ],
857 "stream attached resource",
858 )?;
859 hel::hel_resources::stream_resource(&resource.source, |stream| {
860 execute_checked_with_stdin(executor, &install, stream).map(|_| ())
861 })
862 .with_context(|| format!("stream attached resource {}", resource.source.display()))?;
863 }
864 Ok(())
865}
866
867pub(super) fn execute_concurrent_lanes<A: Send, B: Send>(
871 first: impl FnOnce() -> Result<A> + Send,
872 second: impl FnOnce() -> Result<B> + Send,
873) -> Result<(A, B)> {
874 std::thread::scope(|scope| {
875 let second = scope.spawn(second);
876 let first = first();
877 let second = second.join().unwrap_or_else(|panic| {
878 Err(anyhow::anyhow!(
879 "concurrent target lane panicked: {}",
880 hel_targets::command_thread_panic_message(panic.as_ref())
881 ))
882 });
883 match (first, second) {
884 (Err(error), _) => Err(error),
885 (Ok(_), Err(error)) => Err(error),
886 (Ok(first), Ok(second)) => Ok((first, second)),
887 }
888 })
889}
890
891fn execute_repository_setup(
892 plan: &hel_targets::CommandPlan,
893 executor: &(impl CommandExecutor + Sync),
894) -> Result<()> {
895 if plan.commands.is_empty() {
896 return Ok(());
897 }
898 let _cloning = ProvisionStageGuard::new(executor, ProvisionStage::Cloning);
899 plan.execute_concurrent(executor).map(|_| ())
900}
901
902#[cfg(test)]
909fn provision_target(
910 plan: &hel_targets::CommandPlan,
911 target: &hel_targets::TargetTemplate,
912 session_id: &str,
913 executor: &(impl CommandExecutor + Sync),
914 discover: impl FnOnce(&[CommandOutput]) -> Result<TargetLocator>,
915) -> Result<TargetLocator> {
916 let Some((creation, remainder)) = plan.split_at_target_creation() else {
917 return discover(&plan.execute_concurrent(executor)?);
919 };
920 let mut outputs = creation.execute_concurrent(executor)?;
921 let result = match remainder.execute_concurrent(executor) {
922 Ok(rest) => {
923 outputs.extend(rest);
924 discover(&outputs)
925 }
926 Err(error) => Err(error),
927 };
928 result.map_err(|error| {
929 match cleanup_failed_provision(target, session_id, outputs.first(), executor) {
930 Some(note) => error.context(note),
931 None => error,
932 }
933 })
934}
935
936fn provision_target_creation(
940 plan: &hel_targets::CommandPlan,
941 target: &hel_targets::TargetTemplate,
942 session_id: &str,
943 executor: &(impl CommandExecutor + Sync),
944 discover: impl FnOnce(&[CommandOutput]) -> Result<TargetLocator>,
945) -> Result<(TargetLocator, hel_targets::CommandPlan)> {
946 let Some((creation, remainder)) = plan.split_at_target_creation() else {
947 let outputs = plan.execute_concurrent(executor)?;
950 return discover(&outputs).map(|locator| {
951 (
952 locator,
953 hel_targets::CommandPlan {
954 description: plan.description.clone(),
955 commands: Vec::new(),
956 },
957 )
958 });
959 };
960 let outputs = creation.execute_concurrent(executor)?;
961 discover(&outputs)
962 .map(|locator| (locator, remainder))
963 .map_err(|error| {
964 match cleanup_failed_provision(target, session_id, outputs.first(), executor) {
965 Some(note) => error.context(note),
966 None => error,
967 }
968 })
969}
970
971fn cleanup_failed_provision(
978 target: &hel_targets::TargetTemplate,
979 session_id: &str,
980 create_output: Option<&CommandOutput>,
981 executor: &impl CommandExecutor,
982) -> Option<String> {
983 let locator = provisioned_locator(target, session_id, create_output)?;
984 let leak = format!(
985 "the resource may still exist; find it via its dev.mj.session={session_id} label/tag"
986 );
987 let plan = match hel_targets::close_plan(&locator, session_id) {
988 Ok(plan) => plan,
989 Err(error) => {
990 tracing::warn!(
991 session_id,
992 error = format!("{error:#}"),
993 "could not build provisioning cleanup plan"
994 );
995 return Some(format!("cleanup FAILED: {error:#}; {leak}"));
996 }
997 };
998 let purpose = plan
999 .commands
1000 .iter()
1001 .map(|command| command.purpose.clone())
1002 .collect::<Vec<_>>()
1003 .join("; ");
1004 let Err(error) = plan.execute(executor) else {
1005 return Some(format!("cleanup succeeded: {purpose}"));
1006 };
1007 match hel_targets::cleanup_target_is_confirmed_absent(&locator, session_id, executor) {
1008 Ok(true) => Some(format!("cleanup succeeded: {purpose}")),
1009 Ok(false) => {
1010 tracing::warn!(
1011 session_id,
1012 error = format!("{error:#}"),
1013 "provisioning cleanup failed and the target may still exist"
1014 );
1015 Some(format!("cleanup FAILED ({purpose}): {error:#}; {leak}"))
1016 }
1017 Err(confirm_error) => {
1018 tracing::warn!(
1019 session_id,
1020 error = format!("{confirm_error:#}"),
1021 "could not confirm whether the failed provisioning target was removed"
1022 );
1023 Some(format!(
1024 "cleanup FAILED ({purpose}): {error:#}; checking whether it was removed also failed: {confirm_error:#}; {leak}"
1025 ))
1026 }
1027 }
1028}
1029
1030fn provisioned_locator(
1035 target: &hel_targets::TargetTemplate,
1036 session_id: &str,
1037 create_output: Option<&CommandOutput>,
1038) -> Option<hel_targets::TargetLocator> {
1039 let container_id = || hel_targets::resource_name(session_id).ok();
1040 Some(match target {
1041 hel_targets::TargetTemplate::LocalBare => return None,
1044 hel_targets::TargetTemplate::LocalPodman(container) => {
1045 hel_targets::TargetLocator::LocalPodman {
1046 container_id: container_id()?,
1047 workspace_storage: hel_targets::podman_workspace_locator(container, session_id)
1048 .ok()?,
1049 }
1050 }
1051 hel_targets::TargetTemplate::LocalDocker(_) => hel_targets::TargetLocator::LocalDocker {
1052 container_id: container_id()?,
1053 },
1054 hel_targets::TargetTemplate::AppleContainer(_) => {
1055 hel_targets::TargetLocator::AppleContainer {
1056 container_id: container_id()?,
1057 }
1058 }
1059 hel_targets::TargetTemplate::SshPodman { ssh, container } => {
1060 hel_targets::TargetLocator::SshPodman {
1061 ssh: ssh.clone(),
1062 container_id: container_id()?,
1063 workspace_storage: hel_targets::podman_workspace_locator(container, session_id)
1064 .ok()?,
1065 }
1066 }
1067 hel_targets::TargetTemplate::SshBare { ssh, .. } => hel_targets::TargetLocator::SshBare {
1068 ssh: ssh.clone(),
1069 workspace: hel_targets::workspace_for(target, session_id).ok()?,
1070 },
1071 hel_targets::TargetTemplate::AwsEc2(aws) => hel_targets::TargetLocator::AwsEc2 {
1072 profile: aws.profile.clone(),
1073 region: aws.region.clone(),
1074 instance_id: serde_json::from_slice::<serde_json::Value>(&create_output?.stdout)
1075 .ok()?
1076 .pointer("/Instances/0/InstanceId")?
1077 .as_str()?
1078 .to_owned(),
1079 ssh: aws.ssh.clone(),
1080 workspace: hel_targets::workspace_for(target, session_id).ok()?,
1081 },
1082 })
1083}
1084
1085fn local_branch(repository: &Path) -> Result<String> {
1086 let output = Command::new("git")
1087 .args(["symbolic-ref", "--quiet", "--short", "HEAD"])
1088 .current_dir(repository)
1089 .output()
1090 .with_context(|| format!("read current branch in {}", repository.display()))?;
1091 if !output.status.success() {
1092 bail!(
1093 "local repository {} must have a branch checked out before Hel can expose it as origin",
1094 repository.display()
1095 );
1096 }
1097 let branch = String::from_utf8(output.stdout).context("decode local Git branch")?;
1098 let branch = branch.trim().to_owned();
1099 if branch.is_empty() {
1100 bail!("local repository has an empty current branch");
1101 }
1102 Ok(branch)
1103}
1104
1105fn local_origin_url(worker_root: &str, repository_id: &str) -> String {
1106 fn ext_argument(value: &str) -> String {
1107 value.replace('%', "%%").replace(' ', "% ")
1108 }
1109 format!(
1110 "ext::{}/hel worker git-proxy --root {} --repository {} %S",
1111 ext_argument(worker_root),
1112 ext_argument(worker_root),
1113 repository_id,
1114 )
1115}
1116
1117fn seed_sources<'a>(
1118 missing: &[(&'a hel::hel_config::ProjectRepository, &'a PathBuf)],
1119 bootstrap: &'a LocalBootstrap,
1120) -> Option<Vec<(&'a hel::hel_config::ProjectRepository, &'a PathBuf)>> {
1121 let checkout = match bootstrap {
1122 LocalBootstrap::Skip => return None,
1123 LocalBootstrap::Seed => None,
1124 LocalBootstrap::SeedFrom(checkout) => Some(checkout),
1125 };
1126 Some(
1127 missing
1128 .iter()
1129 .map(|(repository, source)| (*repository, checkout.unwrap_or(source)))
1130 .collect(),
1131 )
1132}
1133
1134fn bootstrap_local_repositories(
1138 executor: &impl CommandExecutor,
1139 locator: &hel_targets::TargetLocator,
1140 session: &SessionRecord,
1141 bundle: &ProjectBundle,
1142 workspace_root: &str,
1143 worker_root: &str,
1144 repositories: &[(&hel::hel_config::ProjectRepository, &PathBuf)],
1145) -> Result<()> {
1146 let snapshots = repositories
1147 .iter()
1148 .map(|(repository, source)| {
1149 collect_git_snapshot(
1150 &SystemGit,
1151 source,
1152 &GitCollectionSpec {
1153 id: repository.id.clone(),
1154 relative_destination: repository.destination.clone(),
1155 history: GitHistoryMode::NoBundle,
1156 origin_override: Some(format!("mj-local:{}", repository.id)),
1157 },
1158 )
1159 .with_context(|| format!("snapshot local repository {:?}", repository.id))
1160 })
1161 .collect::<Result<Vec<_>>>()?;
1162 let staging = data_dir().join("git-seeds");
1163 std::fs::create_dir_all(&staging)?;
1164 let archive_path = staging.join(format!("{}.hel.zip", session.id));
1165 write_archive_atomic(
1166 &archive_path,
1167 &ArchiveInput {
1168 session: SessionManifest {
1169 id: session.id.clone(),
1170 title: session.title.clone(),
1171 harness_kind: session.harness_kind,
1172 profile_id: session.last_profile.clone(),
1173 native_session_id: session.native_session_id.clone().unwrap_or_default(),
1174 created_at: session.created_at.clone(),
1175 checkpointed_at: now(),
1176 hel_version: env!("CARGO_PKG_VERSION").into(),
1177 relay_version: env!("CARGO_PKG_VERSION").into(),
1178 adapter_version: "acp-v1".into(),
1179 },
1180 target: TargetManifest {
1181 template_id: session.target_template_id.clone(),
1182 target_kind: target_kind(locator).into(),
1183 details: Default::default(),
1184 },
1185 bundle: BundleManifest {
1186 id: session.bundle_id.clone(),
1187 primary_repository: bundle.primary_repo.clone(),
1188 },
1189 canonical_session: canonical_session_from_materialized(
1190 &hel::hel_state::MaterializedSession::empty(session.id.clone()),
1191 )?,
1192 native_artifacts: Vec::new(),
1193 repositories: snapshots,
1194 },
1195 )?;
1196
1197 let remote_archive = format!("{worker_root}/local-seed.hel.zip");
1198 let remote_spec = format!("{worker_root}/local-seed.json");
1199 let target_path = |path: &str| match locator {
1200 hel_targets::TargetLocator::AwsEc2 { .. } | hel_targets::TargetLocator::SshBare { .. } => {
1201 PathBuf::from(format!("~/{path}"))
1202 }
1203 _ => PathBuf::from(path),
1204 };
1205 let spec = RepositoryRestoreSpec {
1206 archive_path: target_path(&remote_archive),
1207 workspace_root: target_path(workspace_root),
1208 };
1209 let local_spec = staging.join(format!("{}.json", session.id));
1210 hel::hel_config::atomic_write(&local_spec, &serde_json::to_vec_pretty(&spec)?)?;
1211 upload_checkpoint_spec(
1212 executor,
1213 locator,
1214 &session.id,
1215 &archive_path,
1216 &remote_archive,
1217 )?;
1218 upload_checkpoint_spec(executor, locator, &session.id, &local_spec, &remote_spec)?;
1219 execute_checked(
1220 executor,
1221 hel_targets::command_on_locator(
1222 locator,
1223 &session.id,
1224 vec![
1225 format!("{worker_root}/hel"),
1226 "worker".into(),
1227 "restore-repositories".into(),
1228 "--spec".into(),
1229 remote_spec,
1230 ],
1231 "restore local repository bootstrap",
1232 )?,
1233 )?;
1234 Ok(())
1235}
1236
1237#[derive(Debug, Clone)]
1239struct BrokerFiles {
1240 spec: PathBuf,
1241 ready: PathBuf,
1242 pid: PathBuf,
1243 log: PathBuf,
1244}
1245
1246impl BrokerFiles {
1247 fn in_directory(directory: &Path, session_id: &str) -> Self {
1248 Self {
1249 spec: directory.join(format!("{session_id}.json")),
1250 ready: directory.join(format!("{session_id}.ready")),
1251 pid: directory.join(format!("{session_id}.pid")),
1252 log: directory.join(format!("{session_id}.log")),
1253 }
1254 }
1255}
1256
1257const BROKER_READY_TIMEOUT: Duration = Duration::from_secs(10);
1259
1260const BROKER_RESTART_ATTEMPTS: u32 = 5;
1263
1264const BROKER_RESTART_BACKOFF: Duration = Duration::from_millis(250);
1266
1267const BROKER_HEALTHY_RUN: Duration = Duration::from_secs(30);
1270
1271const BROKER_STOP_GRACE: Duration = Duration::from_secs(2);
1274
1275const BROKER_STOP_POLL: Duration = Duration::from_millis(25);
1277
1278fn broker_directory() -> PathBuf {
1279 data_dir().join("git-brokers")
1280}
1281
1282fn ensure_git_broker(
1283 session_id: &str,
1284 locator: &hel_targets::TargetLocator,
1285 repositories: BTreeMap<String, PathBuf>,
1286) -> Result<()> {
1287 let directory = broker_directory();
1288 std::fs::create_dir_all(&directory)?;
1289 let files = BrokerFiles::in_directory(&directory, session_id);
1290 let spec = GitBrokerSpec {
1291 session_id: session_id.to_owned(),
1292 bridge: hel_targets::git_bridge_command(locator, session_id)?,
1293 repositories,
1294 ready_path: files.ready.clone(),
1295 pid_path: files.pid.clone(),
1296 };
1297 if broker_is_alive(&files.pid) {
1298 if broker_serves(&files, &spec) {
1299 return Ok(());
1300 }
1301 bail!(
1302 "a different local Git broker is still active for session {session_id}; close its target before reconnecting"
1303 );
1304 }
1305 spec.write(&files.spec)?;
1306 let child = match start_git_broker(&files) {
1307 Ok(child) => child,
1308 Err(error) if broker_serves(&files, &spec) => {
1312 tracing::debug!(
1313 session_id,
1314 error = format!("{error:#}"),
1315 "reused a concurrently started local Git broker"
1316 );
1317 return Ok(());
1318 }
1319 Err(error) => return Err(error),
1320 };
1321 let session_id = session_id.to_owned();
1324 std::thread::spawn(move || supervise_git_broker(&session_id, &files, child));
1325 Ok(())
1326}
1327
1328fn broker_serves(files: &BrokerFiles, spec: &GitBrokerSpec) -> bool {
1330 broker_is_alive(&files.pid)
1331 && files.ready.exists()
1332 && GitBrokerSpec::read(&files.spec).is_ok_and(|existing| &existing == spec)
1333}
1334
1335fn start_git_broker(files: &BrokerFiles) -> Result<std::process::Child> {
1337 if !broker_is_alive(&files.pid)
1341 && let Err(error) = std::fs::remove_file(&files.ready)
1342 && error.kind() != std::io::ErrorKind::NotFound
1343 {
1344 tracing::warn!(
1345 path = %files.ready.display(),
1346 %error,
1347 "could not remove stale Git broker ready marker"
1348 );
1349 }
1350 let log = std::fs::OpenOptions::new()
1351 .create(true)
1352 .append(true)
1353 .open(&files.log)
1354 .with_context(|| format!("open Git broker log {}", files.log.display()))?;
1355 let stderr = log.try_clone()?;
1356 let executable = std::env::current_exe().context("locate Hel controller executable")?;
1357 let mut command = Command::new(executable);
1358 command
1359 .args(["broker", "--spec"])
1360 .arg(&files.spec)
1361 .stdin(Stdio::null())
1362 .stdout(log)
1363 .stderr(stderr);
1364 #[cfg(unix)]
1365 {
1366 use std::os::unix::process::CommandExt;
1367 command.process_group(0);
1368 }
1369 let mut child = command.spawn().context("start local Git broker")?;
1370 let deadline = Instant::now() + BROKER_READY_TIMEOUT;
1371 loop {
1372 if files.ready.exists() && broker_is_alive(&files.pid) {
1373 return Ok(child);
1374 }
1375 if let Some(status) = child.try_wait().context("poll local Git broker")? {
1376 bail!(
1377 "local Git broker exited with {status}; see {}",
1378 files.log.display()
1379 );
1380 }
1381 if Instant::now() >= deadline {
1382 if let Err(error) = child.kill() {
1384 tracing::warn!(%error, "could not terminate timed-out Git broker");
1385 }
1386 if let Err(error) = child.wait() {
1387 tracing::warn!(%error, "could not reap timed-out Git broker");
1388 }
1389 bail!(
1390 "timed out starting local Git broker; see {}",
1391 files.log.display()
1392 );
1393 }
1394 std::thread::sleep(Duration::from_millis(50));
1395 }
1396}
1397
1398pub(super) fn retire_git_broker(session_id: &str) -> Result<()> {
1406 retire_broker_files(&BrokerFiles::in_directory(&broker_directory(), session_id))
1407}
1408
1409fn retire_broker_files(files: &BrokerFiles) -> Result<()> {
1419 remove_broker_file(&files.spec)?;
1420 stop_running_broker(&files.pid)?;
1421 remove_broker_file(&files.ready)?;
1422 remove_broker_file(&files.pid)?;
1423 Ok(())
1426}
1427
1428fn broker_needs_restart(files: &BrokerFiles) -> bool {
1435 files.spec.exists() && !broker_is_alive(&files.pid)
1436}
1437
1438fn remove_broker_file(path: &Path) -> Result<()> {
1439 match std::fs::remove_file(path) {
1440 Ok(()) => Ok(()),
1441 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
1442 Err(error) => {
1443 Err(error).with_context(|| format!("remove Git broker file {}", path.display()))
1444 }
1445 }
1446}
1447
1448fn stop_running_broker(pid_path: &Path) -> Result<()> {
1455 for escalate in [false, true] {
1456 let deadline = Instant::now() + BROKER_STOP_GRACE;
1457 loop {
1458 if !broker_is_alive(pid_path) {
1459 return Ok(());
1460 }
1461 if let Some(pid) = running_broker_pid(pid_path) {
1462 stop_broker_process_group(pid, escalate);
1463 }
1464 if Instant::now() >= deadline {
1465 break;
1466 }
1467 std::thread::sleep(BROKER_STOP_POLL);
1468 }
1469 }
1470 bail!(
1471 "the local Git broker holding {} did not stop",
1472 pid_path.display()
1473 )
1474}
1475
1476#[cfg(unix)]
1480fn stop_broker_process_group(pid: i32, escalate: bool) {
1481 hel::hel_subprocess::terminate_process_group(
1482 pid,
1483 if escalate {
1484 libc::SIGKILL
1485 } else {
1486 libc::SIGTERM
1487 },
1488 );
1489}
1490
1491#[cfg(not(unix))]
1492fn stop_broker_process_group(_pid: i32, _escalate: bool) {}
1493
1494fn supervise_git_broker(session_id: &str, files: &BrokerFiles, child: std::process::Child) {
1497 let mut started = Some(child);
1498 let outcome = supervise_broker_restarts(
1499 BROKER_RESTART_ATTEMPTS,
1500 || broker_needs_restart(files),
1501 || {
1502 let running = Instant::now();
1503 let mut child = match started.take() {
1504 Some(child) => child,
1505 None => start_git_broker(files)?,
1506 };
1507 let status = child.wait().context("wait for the local Git broker")?;
1508 Ok((running.elapsed(), format!("{status}")))
1509 },
1510 std::thread::sleep,
1511 );
1512 let Err(error) = outcome else {
1513 return;
1514 };
1515 tracing::error!(
1516 session_id,
1517 error = format!("{error:#}"),
1518 "the session's local Git origin is no longer served"
1519 );
1520 use std::io::Write as _;
1523 if let Ok(mut log) = std::fs::OpenOptions::new()
1524 .create(true)
1525 .append(true)
1526 .open(&files.log)
1527 {
1528 let _ = writeln!(log, "[hel {}] local Git origin lost: {error:#}", now());
1529 }
1530}
1531
1532fn supervise_broker_restarts(
1535 attempts: u32,
1536 mut needs_restart: impl FnMut() -> bool,
1537 mut run: impl FnMut() -> Result<(Duration, String)>,
1538 mut back_off: impl FnMut(Duration),
1539) -> Result<()> {
1540 let mut consecutive = 0;
1541 loop {
1542 let failure = match run() {
1543 Ok((ran_for, status)) => {
1544 if ran_for >= BROKER_HEALTHY_RUN {
1545 consecutive = 0;
1546 }
1547 anyhow::anyhow!("the local Git broker exited with {status}")
1548 }
1549 Err(error) => error,
1550 };
1551 if !needs_restart() {
1552 return Ok(());
1553 }
1554 consecutive += 1;
1555 if consecutive > attempts {
1556 return Err(failure.context(format!(
1557 "the local Git broker stopped {consecutive} times in a row and was not restarted again"
1558 )));
1559 }
1560 back_off(BROKER_RESTART_BACKOFF * consecutive);
1561 }
1562}
1563
1564pub(super) fn enforce_overlay_capable_mounts(
1575 target: &hel_targets::TargetTemplate,
1576 mounts: &mut [hel_targets::AdditionalMount],
1577 executor: &impl CommandExecutor,
1578) -> Vec<String> {
1579 let ssh = match target {
1580 hel_targets::TargetTemplate::LocalPodman(_)
1581 | hel_targets::TargetTemplate::LocalDocker(_) => None,
1582 hel_targets::TargetTemplate::SshPodman { ssh, .. } => Some(ssh),
1583 _ => return Vec::new(),
1584 };
1585 let overlaid = mounts
1586 .iter()
1587 .filter(|mount| !mount.read_only)
1588 .map(|mount| mount.source.clone())
1589 .collect::<Vec<_>>();
1590 if overlaid.is_empty() {
1591 return Vec::new();
1592 }
1593 let filesystems = match hel_targets::probe_filesystem_types(ssh, &overlaid, executor) {
1594 Ok(filesystems) => filesystems,
1595 Err(error) => {
1596 tracing::warn!(
1597 error = format!("{error:#}"),
1598 "could not probe attached-directory filesystems; preserving overlay mounts"
1599 );
1600 return vec![format!(
1601 "Could not read the filesystem under the attached directories, so they keep the \
1602 copy-on-write overlay: {error:#}"
1603 )];
1604 }
1605 };
1606 let mut notices = Vec::new();
1607 for (mount, filesystem) in mounts
1608 .iter_mut()
1609 .filter(|mount| !mount.read_only)
1610 .zip(filesystems)
1611 {
1612 let Some(reason) = hel_targets::overlay_unsupported_filesystem(&filesystem) else {
1613 continue;
1614 };
1615 mount.read_only = true;
1616 notices.push(format!(
1617 "Mounted {} read-only: the overlay is unreliable on {filesystem} ({reason}).",
1618 mount.source.display()
1619 ));
1620 }
1621 notices
1622}
1623
1624pub(super) struct StagedExecutor<'a, E: CommandExecutor> {
1628 inner: &'a E,
1629 stage: ProvisionStage,
1630 _guard: ProvisionStageGuard<'a, E>,
1631}
1632
1633impl<'a, E: CommandExecutor> StagedExecutor<'a, E> {
1634 pub(crate) fn new(inner: &'a E, stage: ProvisionStage) -> Self {
1635 Self {
1636 inner,
1637 stage,
1638 _guard: ProvisionStageGuard::new(inner, stage),
1639 }
1640 }
1641
1642 fn staged(&self, command: &CommandSpec) -> CommandSpec {
1643 if command.stage.is_some() {
1644 return command.clone();
1645 }
1646 command.clone().stage(self.stage)
1647 }
1648}
1649
1650impl<E: CommandExecutor> CommandExecutor for StagedExecutor<'_, E> {
1651 fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
1652 self.inner.execute(&self.staged(command))
1653 }
1654
1655 fn cancellation_requested(&self) -> bool {
1656 self.inner.cancellation_requested()
1657 }
1658
1659 fn stage_started(&self, stage: ProvisionStage) {
1660 self.inner.stage_started(stage);
1661 }
1662
1663 fn stage_finished(&self, stage: ProvisionStage) {
1664 self.inner.stage_finished(stage);
1665 }
1666
1667 fn notify_notice(&self, notice: &str) {
1668 self.inner.notify_notice(notice);
1669 }
1670
1671 fn execute_with_stdin(
1672 &self,
1673 command: &CommandSpec,
1674 input: &mut (dyn std::io::Read + Send),
1675 ) -> Result<CommandOutput> {
1676 self.inner.execute_with_stdin(&self.staged(command), input)
1677 }
1678}
1679
1680fn execute_checked_with_stdin(
1681 executor: &impl CommandExecutor,
1682 command: &CommandSpec,
1683 input: &mut (dyn std::io::Read + Send),
1684) -> Result<CommandOutput> {
1685 let output = executor.execute_with_stdin(command, input)?;
1686 if output.status != 0 {
1687 bail!(
1688 "{} failed with status {}: {}",
1689 command.purpose,
1690 output.status,
1691 String::from_utf8_lossy(&output.stderr)
1692 );
1693 }
1694 Ok(output)
1695}
1696
1697pub(super) fn install_inherited_git_settings(
1698 executor: &impl CommandExecutor,
1699 locator: &hel_targets::TargetLocator,
1700 session_id: &str,
1701) -> Result<()> {
1702 let settings = if inherits_controller_git_settings(locator) {
1703 controller_git_settings()?
1704 } else {
1705 BTreeMap::new()
1706 };
1707 for command in inherited_git_setting_commands(locator, session_id, settings)? {
1708 execute_checked(executor, command)?;
1709 }
1710 Ok(())
1711}
1712
1713fn inherits_controller_git_settings(locator: &hel_targets::TargetLocator) -> bool {
1714 !matches!(
1715 locator,
1716 hel_targets::TargetLocator::LocalBare { .. } | hel_targets::TargetLocator::SshBare { .. }
1717 )
1718}
1719
1720fn inherited_git_setting_commands(
1721 locator: &hel_targets::TargetLocator,
1722 session_id: &str,
1723 settings: BTreeMap<String, String>,
1724) -> Result<Vec<CommandSpec>> {
1725 if matches!(locator, hel_targets::TargetLocator::SshBare { .. }) {
1726 return Ok(Vec::new());
1727 }
1728 settings
1729 .into_iter()
1730 .map(|(key, value)| {
1731 hel_targets::command_on_locator(
1732 locator,
1733 session_id,
1734 vec![
1735 "git".into(),
1736 "config".into(),
1737 "--global".into(),
1738 "--replace-all".into(),
1739 "--".into(),
1740 key.clone(),
1741 value,
1742 ],
1743 format!("inherit Git setting {key}"),
1744 )
1745 })
1746 .collect()
1747}
1748
1749fn controller_git_settings() -> Result<BTreeMap<String, String>> {
1750 let output = match Command::new("git")
1751 .args(["config", "--global", "--includes", "--null", "--list"])
1752 .stdin(Stdio::null())
1753 .output()
1754 {
1755 Ok(output) => output,
1756 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(BTreeMap::new()),
1757 Err(error) => return Err(error).context("read controller Git configuration"),
1758 };
1759 if !output.status.success() {
1760 bail!(
1761 "read controller Git configuration failed with status {}: {}",
1762 output.status,
1763 String::from_utf8_lossy(&output.stderr).trim()
1764 );
1765 }
1766 parse_inherited_git_settings(&output.stdout)
1767}
1768
1769fn parse_inherited_git_settings(output: &[u8]) -> Result<BTreeMap<String, String>> {
1770 let mut settings = BTreeMap::new();
1771 for entry in output
1772 .split(|byte| *byte == 0)
1773 .filter(|entry| !entry.is_empty())
1774 {
1775 let entry = std::str::from_utf8(entry).context("decode controller Git configuration")?;
1776 let (key, value) = entry
1777 .split_once('\n')
1778 .with_context(|| format!("controller Git returned malformed entry {entry:?}"))?;
1779 let key = key.to_ascii_lowercase();
1780 if INHERITED_GIT_SETTINGS.contains(&key.as_str()) {
1781 settings.insert(key, value.to_owned());
1782 }
1783 }
1784 Ok(settings)
1785}
1786
1787#[cfg(test)]
1788mod tests {
1789 use std::collections::BTreeMap;
1790
1791 use std::sync::Mutex;
1792
1793 use hel::hel_config::ProjectRepository;
1794 use hel::hel_state::{HelState, SessionRecord, SessionState, TargetLocator};
1795 use hel::hel_targets::{
1796 self, AdditionalMount, ContainerTemplate, ProjectBundleSpec, SshTarget,
1797 };
1798
1799 use super::*;
1800
1801 struct ProbeExecutor {
1804 answer: std::result::Result<&'static str, &'static str>,
1805 notices: Mutex<Vec<String>>,
1806 }
1807
1808 impl ProbeExecutor {
1809 fn answering(answer: &'static str) -> Self {
1810 Self {
1811 answer: Ok(answer),
1812 notices: Mutex::new(Vec::new()),
1813 }
1814 }
1815
1816 fn failing(stderr: &'static str) -> Self {
1817 Self {
1818 answer: Err(stderr),
1819 notices: Mutex::new(Vec::new()),
1820 }
1821 }
1822 }
1823
1824 impl CommandExecutor for ProbeExecutor {
1825 fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
1826 assert_eq!(command.program, "stat", "only the probe may run here");
1827 Ok(match self.answer {
1828 Ok(filesystem) => CommandOutput {
1829 status: 0,
1830 stdout: format!("{filesystem}\n").into_bytes(),
1831 stderr: Vec::new(),
1832 },
1833 Err(stderr) => CommandOutput {
1834 status: 1,
1835 stdout: Vec::new(),
1836 stderr: stderr.as_bytes().to_vec(),
1837 },
1838 })
1839 }
1840
1841 fn notify_notice(&self, notice: &str) {
1842 self.notices.lock().unwrap().push(notice.to_owned());
1843 }
1844 }
1845
1846 fn podman_target() -> hel_targets::TargetTemplate {
1847 hel_targets::TargetTemplate::LocalPodman(ContainerTemplate {
1848 image: "ubuntu:24.04".into(),
1849 pull_policy: Default::default(),
1850 extra_run_args: Vec::new(),
1851 workspace_storage: Default::default(),
1852 })
1853 }
1854
1855 fn probe_bundle() -> ProjectBundleSpec {
1856 ProjectBundleSpec {
1857 primary: "app".into(),
1858 repositories: vec![hel::hel_targets::RepositorySpec {
1859 url: Some("https://github.com/example/app.git".into()),
1860 destination: "app".into(),
1861 git_ref: None,
1862 reference: None,
1863 }],
1864 }
1865 }
1866
1867 #[test]
1868 fn a_source_that_cannot_overlay_is_mounted_read_only_and_reported() {
1869 let executor = ProbeExecutor::answering("nfs");
1870 let mut mounts = vec![AdditionalMount {
1871 source: PathBuf::from("/nfs/share"),
1872 destination: PathBuf::from("/mnt/share"),
1873 read_only: false,
1874 }];
1875
1876 let notices = enforce_overlay_capable_mounts(&podman_target(), &mut mounts, &executor);
1877
1878 assert!(mounts[0].read_only);
1879 assert_eq!(notices.len(), 1);
1880 assert!(
1881 notices[0]
1882 .contains("Mounted /nfs/share read-only: the overlay is unreliable on nfs (network filesystem)"),
1883 "{notices:?}"
1884 );
1885 let plan = hel_targets::provision_plan(
1886 &podman_target(),
1887 "0123456789abcdef0123456789abcdef",
1888 &probe_bundle(),
1889 &mounts,
1890 )
1891 .unwrap();
1892 assert!(
1893 plan.commands[0]
1894 .args
1895 .windows(2)
1896 .any(|args| args == ["--volume", "/nfs/share:/mnt/share:ro"]),
1897 "{:?}",
1898 plan.commands[0].args
1899 );
1900 }
1901
1902 #[test]
1903 fn a_probe_that_cannot_answer_keeps_the_overlay_and_says_so() {
1904 let executor = ProbeExecutor::failing("stat: cannot read file system information");
1905 let mut mounts = vec![AdditionalMount {
1906 source: PathBuf::from("/host/cache"),
1907 destination: PathBuf::from("/mnt/cache"),
1908 read_only: false,
1909 }];
1910
1911 let notices = enforce_overlay_capable_mounts(&podman_target(), &mut mounts, &executor);
1912
1913 assert!(!mounts[0].read_only);
1914 assert_eq!(notices.len(), 1);
1915 assert!(
1916 notices[0].contains("keep the copy-on-write overlay")
1917 && notices[0].contains("cannot read file system information"),
1918 "{notices:?}"
1919 );
1920 let plan = hel_targets::provision_plan(
1921 &podman_target(),
1922 "0123456789abcdef0123456789abcdef",
1923 &probe_bundle(),
1924 &mounts,
1925 )
1926 .unwrap();
1927 assert!(
1928 plan.commands[0]
1929 .args
1930 .windows(2)
1931 .any(|args| args == ["--volume", "/host/cache:/mnt/cache:O"]),
1932 "{:?}",
1933 plan.commands[0].args
1934 );
1935 }
1936
1937 #[test]
1938 fn engines_without_an_overlay_to_lose_are_never_probed() {
1939 struct UnusedExecutor;
1940
1941 impl CommandExecutor for UnusedExecutor {
1942 fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
1943 panic!("this target must not probe: {}", command.program)
1944 }
1945 }
1946
1947 let mut mounts = vec![AdditionalMount {
1948 source: PathBuf::from("/host/cache"),
1949 destination: PathBuf::from("/mnt/cache"),
1950 read_only: false,
1951 }];
1952 for target in [
1953 hel_targets::TargetTemplate::AppleContainer(ContainerTemplate {
1954 image: "ubuntu:24.04".into(),
1955 pull_policy: Default::default(),
1956 extra_run_args: Vec::new(),
1957 workspace_storage: Default::default(),
1958 }),
1959 hel_targets::TargetTemplate::AwsEc2(hel_targets::AwsTemplate {
1960 profile: "default".into(),
1961 region: "us-east-1".into(),
1962 launch_template: "lt-0123456789abcdef0".into(),
1963 launch_template_version: None,
1964 instance_type: None,
1965 ssh: SshTarget {
1966 destination: "ubuntu@example.test".into(),
1967 ssh_args: Vec::new(),
1968 },
1969 }),
1970 ] {
1971 assert!(
1972 enforce_overlay_capable_mounts(&target, &mut mounts, &UnusedExecutor).is_empty()
1973 );
1974 assert!(!mounts[0].read_only);
1975 }
1976 }
1977
1978 #[test]
1981 fn mounts_already_read_only_are_not_probed() {
1982 struct UnusedExecutor;
1983
1984 impl CommandExecutor for UnusedExecutor {
1985 fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
1986 panic!("a read-only mount must not probe: {}", command.program)
1987 }
1988 }
1989
1990 let mut mounts = vec![AdditionalMount {
1991 source: PathBuf::from("/host/cache"),
1992 destination: PathBuf::from("/mnt/cache"),
1993 read_only: true,
1994 }];
1995
1996 assert!(
1997 enforce_overlay_capable_mounts(&podman_target(), &mut mounts, &UnusedExecutor)
1998 .is_empty()
1999 );
2000 }
2001
2002 #[test]
2003 fn failed_new_session_provisioning_discards_provisional_record() {
2004 let session_id = "0123456789abcdef0123456789abcdef";
2005 let record = SessionRecord {
2006 workspace_id: hel::hel_workspace::DEFAULT_WORKSPACE_ID.to_owned(),
2007 archived: false,
2008 container_cpus: None,
2009 container_memory: None,
2010 id: session_id.into(),
2011 title: "new session".into(),
2012 harness_kind: hel::hel_config::HarnessKind::Codex,
2013 last_profile: "codex".into(),
2014 bundle_id: "project".into(),
2015 project_directory: None,
2016 managed_worktree: None,
2017 target_template_id: "podman".into(),
2018 resource_allocation: None,
2019 additional_mounts: Vec::new(),
2020 state: SessionState::Provisioning,
2021 target: None,
2022 native_session_id: None,
2023 acp_session_title: None,
2024 session_title_override: None,
2025 created_at: "2026-08-12T00:00:00Z".into(),
2026 updated_at: "2026-08-12T00:00:00Z".into(),
2027 viewed_through_event_ordinal: 0,
2028 draft_input: String::new(),
2029 last_error: None,
2030 last_checkpoint_error: None,
2031 checkpoint: None,
2032 };
2033 let mut state = HelState::default();
2034 state.sessions.insert(session_id.into(), record);
2035
2036 let result = apply_new_session_provisioning_result(
2037 &mut state,
2038 session_id,
2039 Err(anyhow::anyhow!("container creation failed")),
2040 );
2041
2042 assert!(result.is_err());
2043 assert!(!state.sessions.contains_key(session_id));
2044 }
2045 #[test]
2046 fn failed_new_worker_start_discards_session_only_after_target_cleanup() {
2047 let session_id = "0123456789abcdef0123456789abcdef";
2048 let mut session = SessionRecord {
2049 workspace_id: hel::hel_workspace::DEFAULT_WORKSPACE_ID.to_owned(),
2050 archived: false,
2051 container_cpus: None,
2052 container_memory: None,
2053 id: session_id.into(),
2054 title: "new session".into(),
2055 harness_kind: hel::hel_config::HarnessKind::Kimi,
2056 last_profile: "kimi".into(),
2057 bundle_id: "raw-project".into(),
2058 project_directory: Some("/srv/project".into()),
2059 managed_worktree: None,
2060 target_template_id: "remote".into(),
2061 resource_allocation: None,
2062 additional_mounts: Vec::new(),
2063 state: SessionState::Disconnected,
2064 target: Some(TargetLocator::SshBare {
2065 host: "builder".into(),
2066 workspace: format!(".local/share/hel/workspaces/{session_id}").into(),
2067 worker_id: None,
2068 }),
2069 native_session_id: None,
2070 acp_session_title: None,
2071 session_title_override: None,
2072 created_at: "2026-08-12T00:00:00Z".into(),
2073 updated_at: "2026-08-12T00:00:00Z".into(),
2074 viewed_through_event_ordinal: 0,
2075 draft_input: String::new(),
2076 last_error: None,
2077 last_checkpoint_error: None,
2078 checkpoint: None,
2079 };
2080 let mut cleaned = HelState::default();
2081 cleaned.sessions.insert(session_id.into(), session.clone());
2082
2083 let failure =
2084 apply_failed_new_session_rollback(&mut cleaned, session_id, "ACP startup failed", None);
2085
2086 assert!(!cleaned.sessions.contains_key(session_id));
2087 assert!(
2088 failure
2089 .to_string()
2090 .contains("provisional session discarded")
2091 );
2092
2093 session.state = SessionState::Disconnected;
2094 let mut cleanup_failed = HelState::default();
2095 cleanup_failed.sessions.insert(session_id.into(), session);
2096 let failure = apply_failed_new_session_rollback(
2097 &mut cleanup_failed,
2098 session_id,
2099 "ACP startup failed",
2100 Some("ssh unavailable".into()),
2101 );
2102 let retained = cleanup_failed.sessions.get(session_id).unwrap();
2103 assert_eq!(retained.state, SessionState::Error);
2104 assert!(retained.target.is_some());
2105 assert!(failure.to_string().contains("cleanup"));
2106 }
2107 #[test]
2108 fn launch_failure_is_persisted_separately_from_session_state() {
2109 let directory = tempfile::tempdir().unwrap();
2110 let session_id = "0123456789abcdef0123456789abcdef";
2111 let detail = format!(
2112 "specific startup cause\n{}\nstderr tail survives",
2113 "x".repeat(MAX_LAUNCH_DIAGNOSTIC_BYTES)
2114 );
2115
2116 let path = persist_launch_failure_to(directory.path(), session_id, &detail).unwrap();
2117 let saved = std::fs::read_to_string(path).unwrap();
2118
2119 assert!(saved.contains("specific startup cause"));
2120 assert!(saved.contains("launch diagnostic truncated"));
2121 assert!(saved.contains("stderr tail survives"));
2122 #[cfg(unix)]
2123 {
2124 use std::os::unix::fs::PermissionsExt;
2125 assert_eq!(
2126 std::fs::metadata(directory.path())
2127 .unwrap()
2128 .permissions()
2129 .mode()
2130 & 0o777,
2131 0o700
2132 );
2133 }
2134 }
2135 #[test]
2136 fn inherited_git_settings_allow_only_portable_non_executable_values() {
2137 let settings = parse_inherited_git_settings(
2138 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",
2139 )
2140 .unwrap();
2141
2142 assert_eq!(
2143 settings,
2144 BTreeMap::from([
2145 ("pull.rebase".into(), "true".into()),
2146 ("user.email".into(), "agent@example.test".into()),
2147 ("user.name".into(), "Final User".into()),
2148 ])
2149 );
2150 }
2151 #[test]
2152 fn inherited_git_settings_reject_malformed_or_non_utf8_output() {
2153 assert!(parse_inherited_git_settings(b"user.name\0").is_err());
2154 assert!(parse_inherited_git_settings(b"user.name\n\xff\0").is_err());
2155 }
2156 #[test]
2157 fn inherited_git_settings_target_only_isolated_workers() {
2158 let ssh = SshTarget {
2159 destination: "worker@example.test".into(),
2160 ssh_args: vec!["-p".into(), "2222".into()],
2161 };
2162 let ephemeral = [
2163 hel_targets::TargetLocator::LocalPodman {
2164 container_id: "abcdef012345".into(),
2165 workspace_storage: Default::default(),
2166 },
2167 hel_targets::TargetLocator::AppleContainer {
2168 container_id: "abcdef012346".into(),
2169 },
2170 hel_targets::TargetLocator::AwsEc2 {
2171 profile: "default".into(),
2172 region: "us-east-1".into(),
2173 instance_id: "i-1234567890abcdef0".into(),
2174 ssh: ssh.clone(),
2175 workspace: ".local/share/hel/workspaces/018f9dd2-a3b4-7c8d-9000-123456789abc"
2176 .into(),
2177 },
2178 hel_targets::TargetLocator::SshPodman {
2179 ssh: ssh.clone(),
2180 container_id: "abcdef012347".into(),
2181 workspace_storage: Default::default(),
2182 },
2183 ];
2184 for locator in &ephemeral {
2185 assert!(inherits_controller_git_settings(locator));
2186 let commands = inherited_git_setting_commands(
2187 locator,
2188 "018f9dd2-a3b4-7c8d-9000-123456789abc",
2189 BTreeMap::from([("user.name".into(), "- Agent O'Brien 日本語".into())]),
2190 )
2191 .unwrap();
2192 assert_eq!(commands.len(), 1);
2193 assert!(
2194 commands[0]
2195 .args
2196 .iter()
2197 .any(|argument| argument.contains("user.name"))
2198 );
2199 assert!(
2200 commands[0]
2201 .args
2202 .iter()
2203 .any(|argument| argument.contains("- Agent O'"))
2204 );
2205 }
2206
2207 let persistent = hel_targets::TargetLocator::SshBare {
2208 ssh,
2209 workspace: "/srv/hel/018f9dd2-a3b4-7c8d-9000-123456789abc".into(),
2210 };
2211 let local = hel_targets::TargetLocator::LocalBare {
2212 worker_root: "/var/lib/hel/workers/018f9dd2-a3b4-7c8d-9000-123456789abc".into(),
2213 };
2214 assert!(!inherits_controller_git_settings(&persistent));
2215 assert!(!inherits_controller_git_settings(&local));
2216 assert!(
2217 inherited_git_setting_commands(
2218 &persistent,
2219 "018f9dd2-a3b4-7c8d-9000-123456789abc",
2220 BTreeMap::from([("user.name".into(), "Agent".into())]),
2221 )
2222 .unwrap()
2223 .is_empty()
2224 );
2225 }
2226
2227 #[test]
2228 fn raw_ssh_targets_select_permissions_and_ssh_podman_is_unconstrained() {
2229 let ssh = hel::hel_config::SshConnection {
2230 host: "builder".into(),
2231 user: None,
2232 identity_file: None,
2233 extra_args: Vec::new(),
2234 };
2235 let guardian = TargetTemplate::SshBare {
2236 ssh: ssh.clone(),
2237 permissions: hel::hel_config::PermissionMode::Guardian,
2238 workspace_prefix: ".local/share/hel/workspaces".into(),
2239 };
2240 let podman = TargetTemplate::SshPodman {
2241 ssh: ssh.clone(),
2242 container: hel::hel_config::ContainerTemplate {
2243 image: "example.invalid/agent:latest".into(),
2244 pull_policy: Default::default(),
2245 platform: None,
2246 cpus: None,
2247 memory: None,
2248 environment: BTreeMap::new(),
2249 workspace_storage: Default::default(),
2250 },
2251 };
2252 let yolo = TargetTemplate::SshBare {
2253 ssh,
2254 permissions: hel::hel_config::PermissionMode::Yolo,
2255 workspace_prefix: ".local/share/hel/workspaces".into(),
2256 };
2257
2258 assert_eq!(
2259 TargetTemplate::LocalBare.execution_policy(),
2260 hel::hel_config::ExecutionPolicy::ConfiguredApprovals
2261 );
2262 assert_eq!(
2263 guardian.execution_policy(),
2264 hel::hel_config::ExecutionPolicy::ConfiguredApprovals
2265 );
2266 assert_eq!(
2267 podman.execution_policy(),
2268 hel::hel_config::ExecutionPolicy::Unconstrained
2269 );
2270 assert_eq!(
2271 yolo.execution_policy(),
2272 hel::hel_config::ExecutionPolicy::Unconstrained
2273 );
2274 }
2275 const PROVISIONED_SESSION: &str = "0123456789abcdef0123456789abcdef";
2276
2277 struct RecordingExecutor {
2280 failing_purpose: String,
2281 commands: Mutex<Vec<Vec<String>>>,
2282 }
2283
2284 impl RecordingExecutor {
2285 fn failing(purpose: impl Into<String>) -> Self {
2286 Self {
2287 failing_purpose: purpose.into(),
2288 commands: Mutex::new(Vec::new()),
2289 }
2290 }
2291
2292 fn succeeding() -> Self {
2293 Self::failing(String::new())
2294 }
2295
2296 fn commands(&self) -> Vec<Vec<String>> {
2297 self.commands.lock().unwrap().clone()
2298 }
2299 }
2300
2301 impl CommandExecutor for RecordingExecutor {
2302 fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
2303 let mut argv = vec![command.program.clone()];
2304 argv.extend(command.args.clone());
2305 self.commands.lock().unwrap().push(argv);
2306 Ok(CommandOutput {
2307 status: i32::from(command.purpose == self.failing_purpose),
2308 stdout: Vec::new(),
2309 stderr: b"the step failed".to_vec(),
2310 })
2311 }
2312 }
2313
2314 fn container_targets() -> Vec<hel_targets::TargetTemplate> {
2315 let container = ContainerTemplate {
2316 image: "ubuntu:24.04".into(),
2317 pull_policy: Default::default(),
2318 extra_run_args: Vec::new(),
2319 workspace_storage: Default::default(),
2320 };
2321 vec![
2322 hel_targets::TargetTemplate::LocalPodman(container.clone()),
2323 hel_targets::TargetTemplate::AppleContainer(container.clone()),
2324 hel_targets::TargetTemplate::SshPodman {
2325 ssh: SshTarget {
2326 destination: "dev@example.test".into(),
2327 ssh_args: vec!["-o".into(), "BatchMode=yes".into()],
2328 },
2329 container,
2330 },
2331 ]
2332 }
2333
2334 #[test]
2335 fn a_failure_after_the_container_exists_removes_it_and_keeps_the_original_error() {
2336 let name = hel_targets::resource_name(PROVISIONED_SESSION).unwrap();
2337 for target in container_targets() {
2338 let plan =
2339 hel_targets::provision_plan(&target, PROVISIONED_SESSION, &probe_bundle(), &[])
2340 .unwrap();
2341 let executor = RecordingExecutor::failing("clone app");
2342
2343 let error = provision_target(&plan, &target, PROVISIONED_SESSION, &executor, |_| {
2344 unreachable!("locator discovery must not run after a failed plan")
2345 })
2346 .unwrap_err();
2347
2348 let reported = format!("{error:#}");
2349 assert!(reported.contains("clone app failed"), "{reported}");
2350 assert!(reported.contains("cleanup succeeded"), "{reported}");
2351 let removal = executor
2353 .commands()
2354 .into_iter()
2355 .map(|arguments| arguments.join(" ").replace('\'', ""))
2356 .find(|command| command.contains("rm --force") && command.contains(&name))
2357 .expect("cleanup removes the exact provisioned container");
2358 assert!(removal.contains("rm --force"), "{removal}");
2359 assert!(removal.contains(&name), "{removal}");
2360 }
2361 }
2362
2363 #[test]
2364 fn target_creation_returns_repository_setup_without_running_it() {
2365 let target = podman_target();
2366 let plan = hel_targets::provision_plan(&target, PROVISIONED_SESSION, &probe_bundle(), &[])
2367 .unwrap();
2368 let executor = RecordingExecutor::succeeding();
2369
2370 let (_, repositories) =
2371 provision_target_creation(&plan, &target, PROVISIONED_SESSION, &executor, |_| {
2372 Ok(TargetLocator::LocalPodman {
2373 container_id: hel_targets::resource_name(PROVISIONED_SESSION)?,
2374 workspace_storage: Default::default(),
2375 })
2376 })
2377 .unwrap();
2378
2379 assert_eq!(executor.commands().len(), 1, "only podman run may execute");
2380 assert!(
2381 repositories
2382 .commands
2383 .iter()
2384 .any(|command| command.purpose == "clone app")
2385 );
2386 }
2387
2388 #[test]
2389 fn a_target_whose_creation_failed_is_never_torn_down() {
2390 for target in container_targets() {
2391 let plan =
2392 hel_targets::provision_plan(&target, PROVISIONED_SESSION, &probe_bundle(), &[])
2393 .unwrap();
2394 let creation = plan.split_at_target_creation().unwrap().0;
2395 let executor =
2396 RecordingExecutor::failing(creation.commands.last().unwrap().purpose.clone());
2397
2398 let error = provision_target(&plan, &target, PROVISIONED_SESSION, &executor, |_| {
2399 unreachable!("locator discovery must not run after a failed plan")
2400 })
2401 .unwrap_err();
2402
2403 let reported = format!("{error:#}");
2404 assert!(!reported.contains("cleanup"), "{reported}");
2405 assert!(
2406 !executor
2407 .commands()
2408 .iter()
2409 .any(|argv| argv.join(" ").contains("rm --force")),
2410 "{:?}",
2411 executor.commands()
2412 );
2413 }
2414 }
2415
2416 #[test]
2417 fn a_target_whose_locator_cannot_be_discovered_is_removed_again() {
2418 let target = podman_target();
2419 let plan = hel_targets::provision_plan(&target, PROVISIONED_SESSION, &probe_bundle(), &[])
2420 .unwrap();
2421 let executor = RecordingExecutor::succeeding();
2422
2423 let error = provision_target(&plan, &target, PROVISIONED_SESSION, &executor, |_| {
2424 bail!("the container never reported an address")
2425 })
2426 .unwrap_err();
2427
2428 let reported = format!("{error:#}");
2429 assert!(reported.contains("never reported an address"), "{reported}");
2430 assert!(reported.contains("cleanup succeeded"), "{reported}");
2431 let removal = executor
2432 .commands()
2433 .into_iter()
2434 .map(|arguments| arguments.join(" "))
2435 .find(|command| command.contains("podman rm --force --ignore"))
2436 .expect("cleanup removes the provisioned Podman container");
2437 assert!(removal.contains("podman rm --force --ignore"), "{removal}");
2438 }
2439
2440 #[test]
2443 fn a_bare_project_failure_removes_nothing() {
2444 let target = hel_targets::TargetTemplate::LocalBare;
2445 let plan =
2446 hel_targets::provision_bare_project_plan(&target, PROVISIONED_SESSION, "/srv/project")
2447 .unwrap();
2448 let executor = RecordingExecutor::succeeding();
2449
2450 let error = provision_target(&plan, &target, PROVISIONED_SESSION, &executor, |_| {
2451 bail!("the worker root was unreadable")
2452 })
2453 .unwrap_err();
2454
2455 assert!(!format!("{error:#}").contains("cleanup"));
2456 assert!(executor.commands().is_empty());
2457 }
2458
2459 #[test]
2460 fn a_broker_that_keeps_stopping_is_restarted_a_bounded_number_of_times() {
2461 let mut runs = 0;
2462 let mut waits = Vec::new();
2463
2464 let error = supervise_broker_restarts(
2465 3,
2466 || true,
2467 || {
2468 runs += 1;
2469 Ok((Duration::from_millis(1), "signal: 9".into()))
2470 },
2471 |delay| waits.push(delay),
2472 )
2473 .unwrap_err();
2474
2475 assert_eq!(runs, 4);
2477 assert_eq!(waits.len(), 3);
2478 assert!(waits[0] < waits[2], "{waits:?}");
2479 assert!(
2480 format!("{error:#}").contains("stopped 4 times in a row"),
2481 "{error:#}"
2482 );
2483 }
2484
2485 #[test]
2486 fn a_broker_another_controller_took_over_is_left_alone() {
2487 let mut runs = 0;
2488
2489 supervise_broker_restarts(
2490 3,
2491 || false,
2492 || {
2493 runs += 1;
2494 Ok((Duration::from_millis(1), "exit status: 0".into()))
2495 },
2496 |_| unreachable!("a broker that needs no restart must not be waited on"),
2497 )
2498 .unwrap();
2499
2500 assert_eq!(runs, 1);
2501 }
2502
2503 #[test]
2506 fn a_broker_that_ran_healthily_earns_a_fresh_restart_budget() {
2507 let mut runs = 0;
2508
2509 let error = supervise_broker_restarts(
2510 2,
2511 || true,
2512 || {
2513 runs += 1;
2514 Ok(if runs <= 4 {
2515 (BROKER_HEALTHY_RUN, "signal: 9".into())
2516 } else {
2517 (Duration::from_millis(1), "signal: 9".into())
2518 })
2519 },
2520 |_| (),
2521 )
2522 .unwrap_err();
2523
2524 assert_eq!(runs, 6);
2525 assert!(format!("{error:#}").contains("stopped 3 times in a row"));
2526 }
2527
2528 #[cfg(unix)]
2533 const BROKER_STAND_IN_PID_PATH: &str = "MJ_TEST_BROKER_STAND_IN_PID_PATH";
2534
2535 fn retirable_broker_files(directory: &Path) -> BrokerFiles {
2536 let files = BrokerFiles::in_directory(directory, PROVISIONED_SESSION);
2537 GitBrokerSpec {
2538 session_id: PROVISIONED_SESSION.into(),
2539 bridge: CommandSpec::new("true", Vec::<String>::new()),
2540 repositories: BTreeMap::new(),
2541 ready_path: files.ready.clone(),
2542 pid_path: files.pid.clone(),
2543 }
2544 .write(&files.spec)
2545 .unwrap();
2546 std::fs::write(&files.ready, "ready\n").unwrap();
2547 std::fs::write(&files.log, "broker log\n").unwrap();
2548 files
2549 }
2550
2551 #[cfg(unix)]
2555 #[test]
2556 fn retiring_a_session_stops_its_running_broker_and_reports_nothing() {
2557 if let Some(pid_path) = std::env::var_os(BROKER_STAND_IN_PID_PATH) {
2558 let _slot = crate::hel_git_proxy::claim_broker_pid_file(Path::new(&pid_path)).unwrap();
2559 std::thread::sleep(Duration::from_secs(60));
2562 return;
2563 }
2564
2565 let directory = tempfile::tempdir().unwrap();
2566 let files = retirable_broker_files(directory.path());
2567 let test_name = format!(
2568 "{}::retiring_a_session_stops_its_running_broker_and_reports_nothing",
2569 module_path!()
2570 .strip_prefix("mj_controller::")
2571 .unwrap_or(module_path!())
2572 );
2573 let mut command = Command::new(std::env::current_exe().unwrap());
2574 command
2575 .args(["--exact", &test_name, "--nocapture"])
2576 .env(BROKER_STAND_IN_PID_PATH, &files.pid)
2577 .stdin(Stdio::null())
2578 .stdout(Stdio::null())
2579 .stderr(Stdio::inherit());
2580 {
2581 use std::os::unix::process::CommandExt;
2582 command.process_group(0);
2585 }
2586 let child = command.spawn().unwrap();
2587 let deadline = Instant::now() + Duration::from_secs(30);
2588 while !broker_is_alive(&files.pid) {
2589 assert!(
2590 Instant::now() < deadline,
2591 "the stand-in broker never claimed its slot"
2592 );
2593 std::thread::sleep(Duration::from_millis(10));
2594 }
2595
2596 let (finished, supervised) = std::sync::mpsc::channel();
2597 let supervisor = {
2598 let files = files.clone();
2599 std::thread::spawn(move || {
2600 supervise_git_broker(PROVISIONED_SESSION, &files, child);
2601 let _ = finished.send(());
2602 })
2603 };
2604
2605 retire_broker_files(&files).unwrap();
2606
2607 supervised
2608 .recv_timeout(Duration::from_secs(30))
2609 .expect("the retired broker's supervisor never finished");
2610 supervisor.join().unwrap();
2611 assert!(!broker_is_alive(&files.pid));
2612 assert!(!files.spec.exists());
2613 assert!(!files.pid.exists());
2614 assert!(!files.ready.exists());
2615 assert_eq!(std::fs::read_to_string(&files.log).unwrap(), "broker log\n");
2616 }
2617
2618 #[test]
2621 fn a_retired_broker_is_never_restarted_where_a_dead_one_is() {
2622 let directory = tempfile::tempdir().unwrap();
2623 let files = retirable_broker_files(directory.path());
2624 std::fs::write(&files.pid, "424242").unwrap();
2626 assert!(broker_needs_restart(&files));
2627
2628 retire_broker_files(&files).unwrap();
2629
2630 assert!(!broker_needs_restart(&files));
2631 let mut runs = 0;
2632 supervise_broker_restarts(
2633 BROKER_RESTART_ATTEMPTS,
2634 || broker_needs_restart(&files),
2635 || {
2636 runs += 1;
2637 Ok((Duration::from_millis(1), "signal: 15".into()))
2638 },
2639 |_| unreachable!("a retired broker must never be waited on for a restart"),
2640 )
2641 .unwrap();
2642
2643 assert_eq!(runs, 1);
2645 assert!(!files.spec.exists());
2646 assert!(!files.pid.exists());
2647 assert!(!files.ready.exists());
2648 assert_eq!(std::fs::read_to_string(&files.log).unwrap(), "broker log\n");
2649 }
2650
2651 #[test]
2654 fn retiring_a_session_that_never_had_a_broker_creates_nothing() {
2655 let directory = tempfile::tempdir().unwrap();
2656 let brokers = directory.path().join("git-brokers");
2657
2658 retire_broker_files(&BrokerFiles::in_directory(&brokers, PROVISIONED_SESSION)).unwrap();
2659
2660 assert!(!brokers.exists());
2661 assert!(
2662 std::fs::read_dir(directory.path())
2663 .unwrap()
2664 .next()
2665 .is_none()
2666 );
2667 }
2668
2669 #[test]
2670 fn a_converting_resume_seeds_from_its_own_checkout() {
2671 let repository = ProjectRepository {
2672 id: "project".into(),
2673 github: None,
2674 local: Some(PathBuf::from("/home/dev/project")),
2675 destination: PathBuf::from("project"),
2676 git_ref: None,
2677 };
2678 let configured = PathBuf::from("/home/dev/project");
2679 let missing = vec![(&repository, &configured)];
2680 let checkout = PathBuf::from("/home/dev/project/.mj/worktrees/session");
2681
2682 assert_eq!(seed_sources(&missing, &LocalBootstrap::Skip), None);
2683 assert_eq!(
2684 seed_sources(&missing, &LocalBootstrap::Seed)
2685 .unwrap()
2686 .into_iter()
2687 .map(|(_, source)| source.clone())
2688 .collect::<Vec<_>>(),
2689 vec![configured.clone()]
2690 );
2691 assert_eq!(
2692 seed_sources(&missing, &LocalBootstrap::SeedFrom(checkout.clone()))
2693 .unwrap()
2694 .into_iter()
2695 .map(|(_, source)| source.clone())
2696 .collect::<Vec<_>>(),
2697 vec![checkout]
2698 );
2699 }
2700}