1mod backend;
4mod checkpoint;
5mod git_cache;
6mod lifecycle;
7pub mod move_session;
8mod provisioning;
9mod readiness;
10mod recovery_scan;
11mod resume;
12mod reviewer;
13#[cfg(test)]
14mod test_support;
15mod worker_binary;
16mod worker_restart;
17mod worktree;
18
19use std::collections::BTreeMap;
20use std::fs::{self, File, OpenOptions};
21use std::path::{Path, PathBuf};
22
23use anyhow::{Context, Result, bail, ensure};
24use chrono::Utc;
25
26use hel::hel_config::{
27 HelConfig, ProjectBundle, ProjectRepository, SshConnection, TargetTemplate, atomic_write,
28 container_size_host, data_dir, is_bare_project_target, mount_history_host,
29};
30
31use crate::hel_import::{configured_bundle_for_local, configured_bundle_for_origin};
32use crate::hel_setup::github_repository_from_origin;
33
34const CONFIG_RENAME_JOURNAL: &str = "config-rename.json";
35
36#[derive(Debug, Clone, Copy, serde::Serialize, serde::Deserialize)]
37#[serde(rename_all = "snake_case")]
38enum ConfigRenameKind {
39 Profile,
40 Target,
41}
42
43#[derive(Debug, serde::Serialize, serde::Deserialize)]
44#[serde(deny_unknown_fields)]
45struct ConfigRenameJournal {
46 kind: ConfigRenameKind,
47 old_id: String,
48 new_id: String,
49}
50use hel::hel_local_git::dirty_local_repositories;
51use hel::hel_state::{
52 HelState, HostContainerSize, SessionRecord, SessionResourceAllocation, SessionState,
53 new_session_id, normalize_session_title,
54};
55use hel::hel_targets::{
56 self, AdditionalMount, CommandExecutor, CommandOutput, CommandSpec, SshTarget,
57};
58
59pub(crate) use backend::controller_github_token;
60pub use backend::image_refresh_plan;
61use backend::validate_resource_allocation;
62use provisioning::apply_failed_new_session_rollback;
63pub(crate) use worker_binary::refresh_remote_worker_binary_if_stale;
64
65pub use checkpoint::{
66 CheckpointArtifact, CheckpointDeferred, checkpoint_was_deferred,
67 reconcile_managed_checkpoint_archives,
68};
69pub use recovery_scan::{RecoveryCandidate, RecoveryScan};
70pub use resume::{
71 ResumeRepositorySourceMismatch, ResumeRepositorySourcePreflight, ResumeRepositorySourceReceipt,
72};
73pub use worker_binary::{WorkerBinaryAvailability, worker_binary_prerequisite_for_arch};
74pub use worker_restart::WorkerUpgradeOutcome;
75pub use worktree::{ResumePlan, resume_compatibility};
76
77pub struct Controller {
78 pub config: HelConfig,
79 pub state: HelState,
80}
81
82#[derive(Debug)]
86pub struct ControllerStoreGuard {
87 file: File,
88}
89
90impl ControllerStoreGuard {
91 pub fn acquire() -> Result<Self> {
92 let directory = data_dir();
93 Self::acquire_at(&directory)
94 }
95
96 fn acquire_at(directory: &Path) -> Result<Self> {
97 Self::try_acquire_at(directory)?.with_context(|| {
98 format!(
99 "another Mjolnir controller is already using {}; stop it before starting this command",
100 directory.display()
101 )
102 })
103 }
104
105 pub fn try_acquire() -> Result<Option<Self>> {
107 Self::try_acquire_at(&data_dir())
108 }
109
110 fn try_acquire_at(directory: &Path) -> Result<Option<Self>> {
111 std::fs::create_dir_all(directory)
112 .with_context(|| format!("create controller data directory {}", directory.display()))?;
113 let path = directory.join("controller.lock");
114 let mut options = OpenOptions::new();
115 options.create(true).read(true).write(true);
116 #[cfg(unix)]
117 {
118 use std::os::unix::fs::OpenOptionsExt;
119 options.mode(0o600);
120 }
121 let file = options
122 .open(&path)
123 .with_context(|| format!("open controller lock {}", path.display()))?;
124 match file.try_lock() {
125 Ok(()) => {}
126 Err(std::fs::TryLockError::WouldBlock) => return Ok(None),
127 Err(std::fs::TryLockError::Error(error)) => {
128 return Err(error)
129 .with_context(|| format!("lock controller store {}", directory.display()));
130 }
131 }
132 Ok(Some(Self { file }))
133 }
134
135 pub fn start_database_writer(&self) -> Result<hel::hel_database::DatabaseWriterOwner> {
138 hel::hel_database::start_database_writer()
139 }
140}
141
142impl Drop for ControllerStoreGuard {
143 fn drop(&mut self) {
144 let _ = self.file.unlock();
147 }
148}
149
150#[derive(Debug)]
154pub struct QuickBundleCreation {
155 pub config: HelConfig,
156 pub bundle_id: String,
157}
158
159#[derive(Debug)]
163pub enum QuickBundleFailure {
164 InvalidSource(anyhow::Error),
165 Persistence(anyhow::Error),
166}
167
168impl std::fmt::Display for QuickBundleFailure {
169 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
170 match self {
171 Self::InvalidSource(error) => write!(formatter, "invalid repository source: {error}"),
172 Self::Persistence(error) => write!(formatter, "persist quick bundle: {error}"),
173 }
174 }
175}
176
177impl std::error::Error for QuickBundleFailure {
178 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
179 match self {
180 Self::InvalidSource(error) | Self::Persistence(error) => Some(error.root_cause()),
181 }
182 }
183}
184
185pub fn create_quick_bundle(
191 source: &str,
192) -> std::result::Result<QuickBundleCreation, QuickBundleFailure> {
193 let (config, bundle_id) = HelConfig::update(|config| {
194 create_quick_bundle_in_config(config, source)
195 .map_err(|error| anyhow::Error::new(QuickBundleFailure::InvalidSource(error)))
196 })
197 .map_err(|error| {
198 error
199 .downcast::<QuickBundleFailure>()
200 .unwrap_or_else(QuickBundleFailure::Persistence)
201 })?;
202 Ok(QuickBundleCreation { config, bundle_id })
203}
204
205pub fn create_quick_bundle_in_config(config: &mut HelConfig, source: &str) -> Result<String> {
210 let source = source.trim();
211 if source.is_empty() {
212 bail!("repository source cannot be empty");
213 }
214 let candidate = Path::new(source);
215 let (name, github, local) = if candidate.exists() {
216 let root = hel::hel_local_git::canonical_repository(candidate)?;
217 if let Some(existing) = configured_bundle_for_local(config, &root) {
218 return Ok(existing);
219 }
220 let name = root
221 .file_name()
222 .and_then(|name| name.to_str())
223 .context("local repository has no usable directory name")?
224 .to_owned();
225 (name, None, Some(root))
226 } else {
227 if candidate.is_absolute() || source.starts_with('.') || source.starts_with('~') {
228 bail!("local repository path {source:?} does not exist");
229 }
230 let repository = github_repository_from_origin(source).context(format!(
231 "{source:?} is not a GitHub owner/repository or URL"
232 ))?;
233 if let Some(existing) = configured_bundle_for_origin(config, &repository) {
234 return Ok(existing);
235 }
236 let name = repository.repository.clone();
237 let github = format!("{}/{}", repository.owner, repository.repository);
238 (name, Some(github), None)
239 };
240 let repository_id = quick_config_id(&name);
241 let mut bundle_id = repository_id.clone();
242 for suffix in 2_u32.. {
243 if !config.bundles.contains_key(&bundle_id) {
244 break;
245 }
246 bundle_id = format!("{repository_id}-{suffix}");
247 }
248 config.bundles.insert(
249 bundle_id.clone(),
250 ProjectBundle {
251 primary_repo: repository_id.clone(),
252 repositories: vec![ProjectRepository {
253 id: repository_id.clone(),
254 github,
255 local,
256 destination: PathBuf::from(repository_id),
257 git_ref: None,
258 }],
259 },
260 );
261 config.validate()?;
262 Ok(bundle_id)
263}
264
265fn quick_config_id(value: &str) -> String {
266 let id = value
267 .chars()
268 .filter(|character| {
269 character.is_ascii_alphanumeric() || matches!(character, '-' | '_' | '.')
270 })
271 .take(64)
272 .collect::<String>();
273 if id.is_empty() || matches!(id.as_str(), "." | "..") {
274 "repository".into()
275 } else {
276 id
277 }
278}
279
280pub struct SessionLaunchOptions {
281 pub workspace_id: String,
282 pub additional_mounts: Vec<AdditionalMount>,
283 pub allow_dirty_local: bool,
284 pub resource_allocation: Option<SessionResourceAllocation>,
285 pub project_directory: Option<PathBuf>,
286 pub session_title_override: Option<String>,
287}
288
289pub struct SessionResumeOptions {
290 pub additional_mounts: Option<Vec<AdditionalMount>>,
291 pub resource_allocation: Option<SessionResourceAllocation>,
292 pub discard_queue: bool,
293}
294
295fn selected_host_container_size(
296 template: &TargetTemplate,
297 allocation: Option<&SessionResourceAllocation>,
298) -> Option<(String, HostContainerSize)> {
299 let host = container_size_host(template)?;
300 let SessionResourceAllocation::Container { cpus, memory_bytes } = allocation? else {
301 return None;
302 };
303 Some((
304 host.to_owned(),
305 HostContainerSize {
306 cpus: *cpus,
307 memory_bytes: *memory_bytes,
308 },
309 ))
310}
311
312impl Controller {
313 pub fn load() -> Result<Self> {
314 let config = HelConfig::load()?;
315 let state = HelState::load()?;
316 state.validate_against_config(&config)?;
317 Ok(Self { config, state })
318 }
319
320 pub fn reload(&mut self) -> Result<()> {
321 *self = Self::load()?;
322 Ok(())
323 }
324
325 fn persist_session_state(&self, session_id: &str) -> Result<()> {
326 match self.state.sessions.get(session_id) {
327 Some(session) => hel::hel_database::save_lifecycle_session(session),
328 None => hel::hel_database::delete_session(session_id),
329 }
330 }
331
332 fn persist_session_transition_or_restore(
333 &mut self,
334 session_id: &str,
335 previous: &SessionRecord,
336 context: &'static str,
337 ) -> Result<()> {
338 persist_session_record_transition_or_restore(
339 &mut self.state,
340 session_id,
341 previous,
342 context,
343 &hel::hel_database::save_lifecycle_session,
344 )
345 }
346
347 fn restore_prior_session_after_persistence_failure(
348 &mut self,
349 session_id: &str,
350 previous: &SessionRecord,
351 primary: anyhow::Error,
352 ) -> anyhow::Error {
353 restore_session_after_persistence_failure(
354 &mut self.state,
355 session_id,
356 previous,
357 primary,
358 hel::hel_database::save_lifecycle_session,
359 )
360 }
361
362 pub fn complete_mount_source(
364 &self,
365 target_id: &str,
366 prefix: &str,
367 executor: &impl CommandExecutor,
368 ) -> Result<Vec<String>> {
369 let target = self
370 .config
371 .targets
372 .get(target_id)
373 .with_context(|| format!("unknown target template {target_id:?}"))?;
374 match target {
375 TargetTemplate::LocalPodman { .. }
376 | TargetTemplate::LocalDocker { .. }
377 | TargetTemplate::AppleContainer { .. }
378 | TargetTemplate::AwsEc2 { .. } => Ok(hel_targets::local_directory_completions(prefix)),
379 TargetTemplate::SshPodman { ssh, .. } | TargetTemplate::SshDocker { ssh, .. } => {
380 hel_targets::ssh_directory_completions(&backend_ssh(ssh), prefix, executor)
381 }
382 TargetTemplate::LocalBare | TargetTemplate::SshBare { .. } => {
383 bail!("resource path completion is unsupported for bare targets")
384 }
385 }
386 }
387
388 pub fn validate_mount_source(
395 &self,
396 target_id: &str,
397 source: &Path,
398 executor: &impl CommandExecutor,
399 ) -> Result<Option<String>> {
400 let target = self
401 .config
402 .targets
403 .get(target_id)
404 .with_context(|| format!("unknown target template {target_id:?}"))?;
405 let exists = match target {
406 TargetTemplate::LocalPodman { .. }
407 | TargetTemplate::LocalDocker { .. }
408 | TargetTemplate::AppleContainer { .. }
409 | TargetTemplate::AwsEc2 { .. } => std::fs::metadata(source)
410 .map(|metadata| metadata.is_dir())
411 .or_else(|error| {
412 if error.kind() == std::io::ErrorKind::NotFound {
413 Ok(false)
414 } else {
415 Err(error)
416 }
417 })
418 .with_context(|| format!("inspect resource source {}", source.display()))?,
419 TargetTemplate::SshPodman { ssh, .. } | TargetTemplate::SshDocker { ssh, .. } => {
420 hel_targets::ssh_directory_exists(&backend_ssh(ssh), source, executor)?
421 }
422 TargetTemplate::LocalBare | TargetTemplate::SshBare { .. } => {
423 bail!("resource attachments are unsupported for bare targets")
424 }
425 };
426 ensure!(
427 exists,
428 "source path {} does not exist or is not a directory",
429 source.display()
430 );
431 Ok(self.forced_read_only_reason(target, source, executor))
432 }
433
434 fn forced_read_only_reason(
436 &self,
437 target: &TargetTemplate,
438 source: &Path,
439 executor: &impl CommandExecutor,
440 ) -> Option<String> {
441 let ssh = match target {
442 TargetTemplate::LocalPodman { .. } | TargetTemplate::LocalDocker { .. } => None,
443 TargetTemplate::SshPodman { ssh, .. } | TargetTemplate::SshDocker { ssh, .. } => {
444 Some(backend_ssh(ssh))
445 }
446 _ => return None,
449 };
450 let filesystem = hel_targets::probe_filesystem_types(
451 ssh.as_ref(),
452 std::slice::from_ref(&source.to_path_buf()),
453 executor,
454 )
455 .map_err(|error| {
456 tracing::debug!(
457 source = %source.display(),
458 error = format!("{error:#}"),
459 "could not probe the filesystem under a mount source"
460 );
461 })
462 .ok()?
463 .pop()?;
464 let reason = hel_targets::overlay_unsupported_filesystem(&filesystem)?;
465 Some(format!("{filesystem} ({reason})"))
466 }
467
468 fn fail_new_session_with_cleanup(
469 &mut self,
470 session_id: &str,
471 error: anyhow::Error,
472 executor: &impl CommandExecutor,
473 ) -> Result<anyhow::Error> {
474 let original = format!("{error:#}");
475 let cleanup_error = self
476 .cleanup_new_session_worktree_after_failure(session_id, executor)
477 .err()
478 .map(|cleanup_error| format!("{cleanup_error:#}"));
479 if let Some(cleanup_error) = &cleanup_error {
480 tracing::warn!(
481 session_id,
482 error = %cleanup_error,
483 "new-session worktree rollback reported a cleanup failure"
484 );
485 }
486 let failure = apply_failed_new_session_rollback(
487 &mut self.state,
488 session_id,
489 &original,
490 cleanup_error,
491 );
492 self.persist_session_state(session_id)?;
493 Ok(failure)
494 }
495
496 pub fn register_session_with_resources(
497 &mut self,
498 profile_id: &str,
499 bundle_id: &str,
500 target_id: &str,
501 title: impl Into<String>,
502 options: SessionLaunchOptions,
503 ) -> Result<String> {
504 let SessionLaunchOptions {
505 workspace_id,
506 additional_mounts,
507 allow_dirty_local,
508 resource_allocation,
509 project_directory,
510 session_title_override,
511 } = options;
512 let session_title_override = match session_title_override {
513 Some(title) => {
514 Some(normalize_session_title(&title).context("session name cannot be empty")?)
515 }
516 None => None,
517 };
518 let profile = self
519 .config
520 .profiles
521 .get(profile_id)
522 .with_context(|| format!("unknown profile {profile_id:?}"))?;
523 let template = self
524 .config
525 .targets
526 .get(target_id)
527 .with_context(|| format!("unknown target template {target_id:?}"))?;
528 if project_directory.is_some() != is_bare_project_target(template) {
529 bail!("raw project directories require a bare target, and bare targets require one");
530 }
531 if let Some(path) = &project_directory
532 && (!path.is_absolute()
533 || path
534 .components()
535 .any(|part| part == std::path::Component::ParentDir))
536 {
537 bail!("bare project directory must be an absolute safe path");
538 }
539 let bundle = project_directory
540 .is_none()
541 .then(|| self.config.bundles.get(bundle_id))
542 .flatten();
543 if project_directory.is_none() && bundle.is_none() {
544 bail!("unknown bundle {bundle_id:?}");
545 }
546 if matches!(
547 profile.kind,
548 hel::hel_config::HarnessKind::Deepseek | hel::hel_config::HarnessKind::Muse
549 ) && (!additional_mounts.is_empty()
550 || bundle.is_some_and(|bundle| bundle.repositories.len() > 1))
551 {
552 bail!(
553 "{} ACP supports one workspace root; use a single-repository bundle without attached directories",
554 profile.kind.display_name()
555 );
556 }
557 let dirty = bundle
558 .map(dirty_local_repositories)
559 .transpose()?
560 .unwrap_or_default();
561 if !allow_dirty_local && !dirty.is_empty() {
562 let repositories = dirty
563 .iter()
564 .map(|repository| format!("{} ({})", repository.path.display(), repository.summary))
565 .collect::<Vec<_>>()
566 .join(", ");
567 bail!(
568 "local repositories have uncommitted changes: {repositories}; explicit confirmation is required"
569 );
570 }
571 validate_resource_allocation(template, resource_allocation.as_ref())?;
572 let selected_container_size =
573 selected_host_container_size(template, resource_allocation.as_ref());
574 if !additional_mounts.is_empty() && mount_history_host(template).is_none() {
575 bail!("attached resources are unsupported for this target");
576 }
577 hel_targets::validate_additional_mounts(&additional_mounts)?;
578 let id = new_session_id()?;
579 let now = now();
580 let record = SessionRecord {
581 archived: false,
582 container_cpus: None,
583 container_memory: None,
584 id: id.clone(),
585 workspace_id,
586 title: title.into(),
587 harness_kind: profile.kind,
588 last_profile: profile_id.to_string(),
589 bundle_id: bundle_id.to_string(),
590 project_directory,
591 managed_worktree: None,
592 target_template_id: target_id.to_string(),
593 resource_allocation,
594 additional_mounts: additional_mounts.clone(),
595 state: SessionState::Provisioning,
596 target: None,
597 native_session_id: None,
598 acp_session_title: None,
599 session_title_override,
600 created_at: now.clone(),
601 updated_at: now,
602 viewed_through_event_ordinal: 0,
603 draft_input: String::new(),
604 last_error: None,
605 last_checkpoint_error: None,
606 checkpoint: None,
607 };
608 if let Some((host, size)) = selected_container_size.as_ref() {
612 hel::hel_database::save_session_with_container_size(&record, host, *size)?;
613 } else {
614 hel::hel_database::save_session(&record)?;
615 }
616 self.state.sessions.insert(id.clone(), record);
617 if let Some((host, size)) = selected_container_size {
618 self.state.remember_container_size(&host, size);
619 }
620 if let Some(host) = mount_history_host(template) {
621 match hel::hel_database::remember_mount_sources(host, &additional_mounts) {
625 Ok(()) => self.state.remember_mount_sources(host, &additional_mounts),
626 Err(error) => tracing::warn!(
627 session_id = id,
628 error = format!("{error:#}"),
629 "could not remember the attached resource directories for later suggestions"
630 ),
631 }
632 }
633 Ok(id)
634 }
635
636 pub fn rename_session(&mut self, session_id: &str, title: &str) -> Result<String> {
637 let title = normalize_session_title(title).context("session name cannot be empty")?;
638 ensure!(
639 self.state.sessions.contains_key(session_id),
640 "unknown session {session_id}"
641 );
642 let updated_at = now();
643 hel::hel_database::set_session_title_override(session_id, &title, &updated_at)?;
644 let record = self
645 .state
646 .sessions
647 .get_mut(session_id)
648 .expect("session was checked before updating its title");
649 record.session_title_override = Some(title.clone());
650 record.updated_at = updated_at;
651 Ok(title)
652 }
653
654 pub fn rename_profile_id(&mut self, old_id: &str, new_id: &str) -> Result<()> {
655 hel::hel_config::validate_id("profile", new_id)?;
656 if old_id == new_id {
657 ensure!(
658 self.config.profiles.contains_key(old_id),
659 "unknown profile {old_id:?}"
660 );
661 return Ok(());
662 }
663 let journal = ConfigRenameJournal {
664 kind: ConfigRenameKind::Profile,
665 old_id: old_id.to_owned(),
666 new_id: new_id.to_owned(),
667 };
668 write_config_rename_journal(&journal)?;
669 let (config, ()) = match HelConfig::update(|config| {
670 ensure!(
671 config.profiles.contains_key(old_id),
672 "unknown profile {old_id:?}"
673 );
674 ensure!(
675 !config.profiles.contains_key(new_id),
676 "profile {new_id:?} already exists"
677 );
678 let profile = config
679 .profiles
680 .remove(old_id)
681 .expect("profile was checked in the transaction");
682 config.profiles.insert(new_id.to_owned(), profile);
683 Ok(())
684 }) {
685 Ok(result) => result,
686 Err(error) => {
687 remove_config_rename_journal()
688 .context("remove profile rename journal after config save failed")?;
689 return Err(error).context("save renamed profile configuration");
690 }
691 };
692 self.config = config;
693 hel::hel_test_hooks::reach_test_hook("config_replacement_before_reference_migration")?;
694 if let Err(error) = hel::hel_database::rename_profile_references(old_id, new_id) {
695 let restore = HelConfig::update(|config| {
696 let profile = config
697 .profiles
698 .remove(new_id)
699 .with_context(|| format!("renamed profile {new_id:?} is missing"))?;
700 ensure!(
701 !config.profiles.contains_key(old_id),
702 "cannot restore profile rename: both {old_id:?} and {new_id:?} exist"
703 );
704 config.profiles.insert(old_id.to_owned(), profile);
705 Ok(())
706 });
707 let restored = match restore {
708 Ok((config, ())) => config,
709 Err(restore_error) => {
710 return Err(error).context(format!(
711 "rename profile references; additionally failed to restore config: {restore_error:#}"
712 ));
713 }
714 };
715 self.config = restored;
716 if let Err(restore_error) = remove_config_rename_journal() {
717 return Err(error).context(format!(
718 "rename profile references; additionally failed to remove rename journal: {restore_error:#}"
719 ));
720 }
721 return Err(error).context("rename profile references");
722 }
723 for session in self.state.sessions.values_mut() {
724 if session.last_profile == old_id {
725 session.last_profile = new_id.to_owned();
726 }
727 }
728 remove_config_rename_journal()?;
729 Ok(())
730 }
731
732 pub fn rename_target_id(&mut self, old_id: &str, new_id: &str) -> Result<()> {
733 hel::hel_config::validate_id("target template", new_id)?;
734 if old_id == new_id {
735 ensure!(
736 self.config.targets.contains_key(old_id),
737 "unknown target {old_id:?}"
738 );
739 return Ok(());
740 }
741 let journal = ConfigRenameJournal {
742 kind: ConfigRenameKind::Target,
743 old_id: old_id.to_owned(),
744 new_id: new_id.to_owned(),
745 };
746 write_config_rename_journal(&journal)?;
747 let (config, ()) = match HelConfig::update(|config| {
748 ensure!(
749 config.targets.contains_key(old_id),
750 "unknown target {old_id:?}"
751 );
752 ensure!(
753 !config.targets.contains_key(new_id),
754 "target {new_id:?} already exists"
755 );
756 let target = config
757 .targets
758 .remove(old_id)
759 .expect("target was checked in the transaction");
760 config.targets.insert(new_id.to_owned(), target);
761 Ok(())
762 }) {
763 Ok(result) => result,
764 Err(error) => {
765 remove_config_rename_journal()
766 .context("remove target rename journal after config save failed")?;
767 return Err(error).context("save renamed target configuration");
768 }
769 };
770 self.config = config;
771 hel::hel_test_hooks::reach_test_hook("config_replacement_before_reference_migration")?;
772 if let Err(error) = hel::hel_database::rename_target_references(old_id, new_id) {
773 let restore = HelConfig::update(|config| {
774 let target = config
775 .targets
776 .remove(new_id)
777 .with_context(|| format!("renamed target {new_id:?} is missing"))?;
778 ensure!(
779 !config.targets.contains_key(old_id),
780 "cannot restore target rename: both {old_id:?} and {new_id:?} exist"
781 );
782 config.targets.insert(old_id.to_owned(), target);
783 Ok(())
784 });
785 let restored = match restore {
786 Ok((config, ())) => config,
787 Err(restore_error) => {
788 return Err(error).context(format!(
789 "rename target references; additionally failed to restore config: {restore_error:#}"
790 ));
791 }
792 };
793 self.config = restored;
794 if let Err(restore_error) = remove_config_rename_journal() {
795 return Err(error).context(format!(
796 "rename target references; additionally failed to remove rename journal: {restore_error:#}"
797 ));
798 }
799 return Err(error).context("rename target references");
800 }
801 for session in self.state.sessions.values_mut() {
802 if session.target_template_id == old_id {
803 session.target_template_id = new_id.to_owned();
804 }
805 }
806 remove_config_rename_journal()?;
807 Ok(())
808 }
809
810 pub fn recover_config_id_rename() -> Result<bool> {
814 let path = config_rename_journal_path();
815 let body = match fs::read(&path) {
816 Ok(body) => body,
817 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false),
818 Err(error) => return Err(error).context(format!("read {}", path.display())),
819 };
820 let journal: ConfigRenameJournal =
821 serde_json::from_slice(&body).with_context(|| format!("parse {}", path.display()))?;
822 match journal.kind {
823 ConfigRenameKind::Profile => {
824 HelConfig::update(|config| {
825 finish_config_map_rename(
826 &mut config.profiles,
827 &journal.old_id,
828 &journal.new_id,
829 "profile",
830 )?;
831 Ok(())
832 })?;
833 hel::hel_database::rename_profile_references(&journal.old_id, &journal.new_id)?;
834 }
835 ConfigRenameKind::Target => {
836 HelConfig::update(|config| {
837 finish_config_map_rename(
838 &mut config.targets,
839 &journal.old_id,
840 &journal.new_id,
841 "target",
842 )?;
843 Ok(())
844 })?;
845 hel::hel_database::rename_target_references(&journal.old_id, &journal.new_id)?;
846 }
847 }
848 remove_config_rename_journal()?;
849 Ok(true)
850 }
851
852 pub fn update_session_container_settings(
856 &mut self,
857 session_id: &str,
858 cpus: Option<String>,
859 memory: Option<String>,
860 additional_mounts: Vec<hel_targets::AdditionalMount>,
861 mount_history: Vec<std::path::PathBuf>,
862 ) -> Result<()> {
863 ensure!(
864 self.state.sessions.contains_key(session_id),
865 "unknown session {session_id}"
866 );
867 let cpus = cpus.filter(|value| !value.trim().is_empty());
868 let memory = memory.filter(|value| !value.trim().is_empty());
869 let updated_at = now();
870 hel::hel_database::set_session_container_settings(
871 session_id,
872 cpus.as_deref(),
873 memory.as_deref(),
874 &additional_mounts,
875 &updated_at,
876 )?;
877 if let Some(host) = self
878 .config
879 .targets
880 .get(
881 &self.state.sessions[session_id]
882 .target_template_id
883 .to_owned(),
884 )
885 .and_then(hel::hel_config::mount_history_host)
886 {
887 let host = host.to_owned();
888 hel::hel_database::replace_mount_history(&host, &mount_history)?;
891 hel::hel_database::remember_mount_sources(&host, &additional_mounts)?;
892 self.state.mount_history.insert(host.clone(), mount_history);
893 self.state.remember_mount_sources(&host, &additional_mounts);
894 }
895 let record = self
896 .state
897 .sessions
898 .get_mut(session_id)
899 .expect("session was checked before updating its container settings");
900 record.container_cpus = cpus;
901 record.container_memory = memory;
902 record.additional_mounts = additional_mounts;
903 record.updated_at = updated_at;
904 Ok(())
905 }
906}
907
908fn config_rename_journal_path() -> PathBuf {
909 data_dir().join(CONFIG_RENAME_JOURNAL)
910}
911
912fn write_config_rename_journal(journal: &ConfigRenameJournal) -> Result<()> {
913 let path = config_rename_journal_path();
914 let body = serde_json::to_vec(journal).context("serialize config rename journal")?;
915 atomic_write(&path, &body).with_context(|| format!("write {}", path.display()))
916}
917
918fn remove_config_rename_journal() -> Result<()> {
919 let path = config_rename_journal_path();
920 match fs::remove_file(&path) {
921 Ok(()) => Ok(()),
922 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
923 Err(error) => Err(error).with_context(|| format!("remove {}", path.display())),
924 }
925}
926
927fn finish_config_map_rename<T>(
928 entries: &mut BTreeMap<String, T>,
929 old_id: &str,
930 new_id: &str,
931 kind: &str,
932) -> Result<()> {
933 if let Some(entry) = entries.remove(old_id) {
934 ensure!(
935 !entries.contains_key(new_id),
936 "cannot recover {kind} rename: both {old_id:?} and {new_id:?} exist"
937 );
938 entries.insert(new_id.to_owned(), entry);
939 } else {
940 ensure!(
941 entries.contains_key(new_id),
942 "cannot recover {kind} rename: neither {old_id:?} nor {new_id:?} exists"
943 );
944 }
945 Ok(())
946}
947
948fn target_kind(locator: &hel_targets::TargetLocator) -> &'static str {
949 match locator {
950 hel_targets::TargetLocator::LocalBare { .. } => "local-bare",
951 hel_targets::TargetLocator::LocalPodman { .. } => "local-podman",
952 hel_targets::TargetLocator::LocalDocker { .. } => "local-docker",
953 hel_targets::TargetLocator::AppleContainer { .. } => "apple-container",
954 hel_targets::TargetLocator::AwsEc2 { .. } => "aws-ec2",
955 hel_targets::TargetLocator::SshBare { .. } => "ssh-bare",
956 hel_targets::TargetLocator::SshPodman { .. } => "ssh-podman",
957 hel_targets::TargetLocator::SshDocker { .. } => "ssh-docker",
958 }
959}
960
961fn target_profile_home(
962 locator: &hel_targets::TargetLocator,
963 session_id: &str,
964 profile: &hel::hel_config::HarnessProfile,
965) -> String {
966 let home = match locator {
967 hel_targets::TargetLocator::LocalBare { .. } => profile.home.to_string_lossy().into_owned(),
968 hel_targets::TargetLocator::LocalPodman { .. }
969 | hel_targets::TargetLocator::LocalDocker { .. }
970 | hel_targets::TargetLocator::AppleContainer { .. }
971 | hel_targets::TargetLocator::SshPodman { .. }
972 | hel_targets::TargetLocator::SshDocker { .. } => {
973 format!("/var/lib/hel/profiles/{session_id}")
974 }
975 hel_targets::TargetLocator::AwsEc2 { .. } | hel_targets::TargetLocator::SshBare { .. } => {
976 format!(".local/share/hel/profiles/{session_id}")
977 }
978 };
979 if profile.kind == hel::hel_config::HarnessKind::Muse {
980 let root = if matches!(locator, hel_targets::TargetLocator::LocalBare { .. }) {
981 hel::hel_config::data_dir()
982 .join("profiles")
983 .join(session_id)
984 } else {
985 PathBuf::from(home)
986 };
987 root.join("muse").to_string_lossy().into_owned()
988 } else {
989 home
990 }
991}
992
993pub(crate) fn backend_ssh(ssh: &SshConnection) -> SshTarget {
994 let destination = match &ssh.user {
995 Some(user) => format!("{user}@{}", ssh.host),
996 None => ssh.host.clone(),
997 };
998 SshTarget {
999 destination,
1000 ssh_args: ssh_args_with_identity(&ssh.extra_args, ssh.identity_file.as_deref()),
1001 }
1002}
1003
1004fn ssh_command_spec(
1005 ssh: &SshTarget,
1006 args: impl IntoIterator<Item = impl AsRef<str>>,
1007) -> CommandSpec {
1008 let remote = args
1009 .into_iter()
1010 .map(|arg| arg.as_ref().to_string())
1011 .collect::<Vec<_>>();
1012 let mut command_args = ssh.ssh_args.clone();
1013 command_args.push(ssh.destination.clone());
1014 command_args.push(hel_targets::join_remote_command(&remote));
1015 CommandSpec::new("ssh", command_args)
1016}
1017
1018fn scp_command_spec(ssh: &SshTarget, source: &Path, remote: &str, recursive: bool) -> CommandSpec {
1019 let mut args = ssh.ssh_args.clone();
1020 if recursive {
1021 args.push("-r".into());
1022 }
1023 args.push(source.to_string_lossy().into_owned());
1024 args.push(format!("{}:{remote}", ssh.destination));
1025 CommandSpec::new("scp", args)
1026}
1027
1028fn ssh_args_with_identity(args: &[String], identity: Option<&Path>) -> Vec<String> {
1029 let mut result = vec![
1035 "-o".into(),
1036 "BatchMode=yes".into(),
1037 "-o".into(),
1038 "StrictHostKeyChecking=accept-new".into(),
1039 "-o".into(),
1040 "ConnectTimeout=15".into(),
1041 ];
1042 result.extend(args.iter().cloned());
1043 if let Some(identity) = identity {
1044 result.push("-i".into());
1045 result.push(identity.to_string_lossy().into_owned());
1046 }
1047 result
1048}
1049
1050fn execute_checked(executor: &impl CommandExecutor, command: CommandSpec) -> Result<CommandOutput> {
1051 let output = executor.execute(&command)?;
1052 if output.status != 0 {
1053 let detail = command_error_detail(&output.stderr);
1054 if detail.is_empty() {
1055 bail!("{} failed with status {}", command.purpose, output.status);
1056 }
1057 bail!("{detail}");
1058 }
1059 Ok(output)
1060}
1061
1062fn command_error_detail(stderr: &[u8]) -> String {
1063 let reported = String::from_utf8_lossy(stderr);
1064 let reported = reported.trim();
1065 let detail = reported
1066 .rsplit_once("\nCaused by:\n")
1067 .map_or(reported, |(_, causes)| causes);
1068 let detail = detail.strip_prefix("Error: ").unwrap_or(detail);
1069 detail
1070 .lines()
1071 .map(|line| line.strip_prefix(" ").unwrap_or(line))
1072 .collect::<Vec<_>>()
1073 .join("\n")
1074 .trim()
1075 .to_owned()
1076}
1077
1078fn now() -> String {
1079 Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true)
1080}
1081
1082fn restore_session_after_persistence_failure(
1083 state: &mut HelState,
1084 session_id: &str,
1085 previous: &SessionRecord,
1086 primary: anyhow::Error,
1087 persist: impl FnOnce(&SessionRecord) -> Result<()>,
1088) -> anyhow::Error {
1089 state
1090 .sessions
1091 .insert(session_id.to_owned(), previous.clone());
1092 let restored = state
1093 .sessions
1094 .get(session_id)
1095 .expect("restored session record disappeared");
1096 match persist(restored) {
1097 Ok(()) => primary,
1098 Err(error) => primary.context(format!(
1099 "restored prior session state in memory, but failed to persist the rollback: {error:#}"
1100 )),
1101 }
1102}
1103
1104fn persist_session_record_transition_or_restore(
1105 state: &mut HelState,
1106 session_id: &str,
1107 previous: &SessionRecord,
1108 context: &'static str,
1109 persist: &impl Fn(&SessionRecord) -> Result<()>,
1110) -> Result<()> {
1111 let result = persist(
1112 state
1113 .sessions
1114 .get(session_id)
1115 .expect("checkpoint session disappeared before persistence"),
1116 );
1117 match result {
1118 Ok(()) => Ok(()),
1119 Err(error) => Err(restore_session_after_persistence_failure(
1120 state,
1121 session_id,
1122 previous,
1123 error.context(context),
1124 persist,
1125 )),
1126 }
1127}
1128
1129#[cfg(test)]
1130mod tests {
1131 use std::collections::BTreeMap;
1132 use std::path::Path;
1133
1134 use hel::hel_config::{
1135 ContainerTemplate as ConfigContainer, HarnessKind, HarnessProfile, HelConfig,
1136 ProjectBundle, ProjectRepository, TargetTemplate,
1137 };
1138 use hel::hel_state::HelState;
1139 use hel::hel_targets::ProcessExecutor;
1140
1141 use super::*;
1142
1143 fn registration_config() -> HelConfig {
1146 let mut config = HelConfig::default();
1147 config.profiles.insert(
1148 "codex".into(),
1149 HarnessProfile {
1150 kind: HarnessKind::Codex,
1151 home: PathBuf::from("/home/dev/.codex"),
1152 environment: BTreeMap::new(),
1153 context_window_bytes: None,
1154 },
1155 );
1156 config.bundles.insert(
1157 "project".into(),
1158 ProjectBundle {
1159 primary_repo: "project".into(),
1160 repositories: vec![ProjectRepository {
1161 id: "project".into(),
1162 github: Some("owner/project".into()),
1163 local: None,
1164 destination: PathBuf::from("project"),
1165 git_ref: None,
1166 }],
1167 },
1168 );
1169 config.targets.insert(
1170 "podman".into(),
1171 TargetTemplate::LocalPodman {
1172 container: ConfigContainer {
1173 image: "example.invalid/hel-test:latest".into(),
1174 pull_policy: Default::default(),
1175 platform: None,
1176 cpus: None,
1177 memory: None,
1178 environment: BTreeMap::new(),
1179 workspace_storage: Default::default(),
1180 },
1181 },
1182 );
1183 config
1184 }
1185
1186 fn launch_options(additional_mounts: Vec<AdditionalMount>) -> SessionLaunchOptions {
1187 SessionLaunchOptions {
1188 workspace_id: hel::hel_workspace::DEFAULT_WORKSPACE_ID.to_owned(),
1189 additional_mounts,
1190 allow_dirty_local: false,
1191 resource_allocation: None,
1192 project_directory: None,
1193 session_title_override: None,
1194 }
1195 }
1196
1197 #[test]
1198 fn deepseek_registration_rejects_more_than_one_workspace_root_before_persisting() {
1199 let mut config = registration_config();
1200 config.profiles.get_mut("codex").unwrap().kind = HarnessKind::Deepseek;
1201 let second = config.bundles["project"].repositories[0].clone();
1202 config
1203 .bundles
1204 .get_mut("project")
1205 .unwrap()
1206 .repositories
1207 .push(hel::hel_config::ProjectRepository {
1208 id: "second".into(),
1209 destination: "second".into(),
1210 ..second
1211 });
1212 let mut controller = Controller {
1213 config,
1214 state: HelState::default(),
1215 };
1216
1217 let error = controller
1218 .register_session_with_resources(
1219 "codex",
1220 "project",
1221 "podman",
1222 "unsupported",
1223 launch_options(Vec::new()),
1224 )
1225 .unwrap_err();
1226
1227 assert!(error.to_string().contains("one workspace root"));
1228 assert!(controller.state.sessions.is_empty());
1229 }
1230
1231 fn run_registration_child(marker: &str, test: &str, data_directory: &Path) {
1234 let output = std::process::Command::new(std::env::current_exe().unwrap())
1235 .args([
1236 "--exact",
1237 &format!("hel_controller::tests::{test}"),
1238 "--nocapture",
1239 ])
1240 .env(marker, "1")
1241 .env("MJ_DATA_DIR", data_directory)
1242 .env("MJ_CONFIG_DIR", data_directory)
1243 .output()
1244 .unwrap();
1245 assert!(
1246 output.status.success(),
1247 "isolated {test} failed\nstdout:\n{}\nstderr:\n{}",
1248 String::from_utf8_lossy(&output.stdout),
1249 String::from_utf8_lossy(&output.stderr)
1250 );
1251 }
1252
1253 const UNPERSISTABLE_SESSION_CHILD: &str = "MJ_TEST_UNPERSISTABLE_SESSION_CHILD";
1254
1255 const CONFIG_ID_RENAME_CHILD: &str = "MJ_TEST_CONFIG_ID_RENAME_CHILD";
1256
1257 #[test]
1258 fn configuration_id_rename_rewrites_durable_session_references() {
1259 if std::env::var_os(CONFIG_ID_RENAME_CHILD).is_none() {
1260 let directory = tempfile::tempdir().unwrap();
1261 run_registration_child(
1262 CONFIG_ID_RENAME_CHILD,
1263 "configuration_id_rename_rewrites_durable_session_references",
1264 directory.path(),
1265 );
1266 return;
1267 }
1268 let _writer = hel::hel_database::install_isolated_test_writer();
1270
1271 let mut controller = Controller {
1272 config: registration_config(),
1273 state: HelState::default(),
1274 };
1275 controller.config.save().unwrap();
1276 let session_id = controller
1277 .register_session_with_resources(
1278 "codex",
1279 "project",
1280 "podman",
1281 "rename references",
1282 launch_options(Vec::new()),
1283 )
1284 .unwrap();
1285
1286 controller
1287 .rename_profile_id("codex", "codex-renamed")
1288 .unwrap();
1289 controller
1290 .rename_target_id("podman", "podman-renamed")
1291 .unwrap();
1292
1293 let loaded = Controller::load().unwrap();
1294 let session = &loaded.state.sessions[&session_id];
1295 assert_eq!(session.last_profile, "codex-renamed");
1296 assert_eq!(session.target_template_id, "podman-renamed");
1297 assert!(loaded.config.profiles.contains_key("codex-renamed"));
1298 assert!(loaded.config.targets.contains_key("podman-renamed"));
1299 assert!(!config_rename_journal_path().exists());
1300 }
1301
1302 #[test]
1303 fn a_session_the_database_rejects_is_never_left_in_memory() {
1304 if std::env::var_os(UNPERSISTABLE_SESSION_CHILD).is_none() {
1305 let directory = tempfile::tempdir().unwrap();
1306 run_registration_child(
1307 UNPERSISTABLE_SESSION_CHILD,
1308 "a_session_the_database_rejects_is_never_left_in_memory",
1309 directory.path(),
1310 );
1311 return;
1312 }
1313 let _writer = hel::hel_database::install_isolated_test_writer();
1315
1316 let mut controller = Controller {
1317 config: registration_config(),
1318 state: HelState::default(),
1319 };
1320 controller
1326 .register_session_with_resources(
1327 "codex",
1328 "project",
1329 "podman",
1330 "first",
1331 launch_options(Vec::new()),
1332 )
1333 .expect("a healthy store registers a session");
1334 rusqlite::Connection::open(hel::hel_database::database_path())
1335 .unwrap()
1336 .execute_batch("DROP TABLE sessions")
1337 .unwrap();
1338
1339 let error = controller
1340 .register_session_with_resources(
1341 "codex",
1342 "project",
1343 "podman",
1344 "unpersistable",
1345 launch_options(Vec::new()),
1346 )
1347 .expect_err("a store that rejects the write cannot register a session");
1348 assert!(
1349 format!("{error:#}").contains("sessions"),
1350 "unexpected error: {error:#}"
1351 );
1352 assert_eq!(
1353 controller.state.sessions.len(),
1354 1,
1355 "a session the database never accepted stayed in controller memory"
1356 );
1357 assert!(
1358 controller
1359 .state
1360 .sessions
1361 .values()
1362 .all(|session| session.title != "unpersistable"),
1363 "the rejected session is the one that stayed"
1364 );
1365 }
1366
1367 const MOUNT_HISTORY_FAILURE_CHILD: &str = "MJ_TEST_MOUNT_HISTORY_FAILURE_CHILD";
1368
1369 const CONTAINER_SIZE_HISTORY_CHILD: &str = "MJ_TEST_CONTAINER_SIZE_HISTORY_CHILD";
1370
1371 #[test]
1372 fn registration_remembers_launch_size_but_session_overrides_do_not_replace_it() {
1373 if std::env::var_os(CONTAINER_SIZE_HISTORY_CHILD).is_none() {
1374 let directory = tempfile::tempdir().unwrap();
1375 run_registration_child(
1376 CONTAINER_SIZE_HISTORY_CHILD,
1377 "registration_remembers_launch_size_but_session_overrides_do_not_replace_it",
1378 directory.path(),
1379 );
1380 return;
1381 }
1382 let _writer = hel::hel_database::install_isolated_test_writer();
1384
1385 let mut controller = Controller {
1386 config: registration_config(),
1387 state: HelState::default(),
1388 };
1389 let mut options = launch_options(Vec::new());
1390 options.resource_allocation = Some(SessionResourceAllocation::Container {
1391 cpus: 12,
1392 memory_bytes: 48 * 1024 * 1024 * 1024,
1393 });
1394 let id = controller
1395 .register_session_with_resources("codex", "project", "podman", "sized", options)
1396 .unwrap();
1397 let expected = HostContainerSize {
1398 cpus: 12,
1399 memory_bytes: 48 * 1024 * 1024 * 1024,
1400 };
1401 assert_eq!(controller.state.container_sizes["local"], expected);
1402 assert_eq!(HelState::load().unwrap().container_sizes["local"], expected);
1403
1404 controller
1405 .update_session_container_settings(
1406 &id,
1407 Some("2".into()),
1408 Some("4g".into()),
1409 Vec::new(),
1410 Vec::new(),
1411 )
1412 .unwrap();
1413 assert_eq!(HelState::load().unwrap().container_sizes["local"], expected);
1414 }
1415
1416 #[test]
1417 fn a_failed_mount_history_write_does_not_fail_the_registered_session() {
1418 if std::env::var_os(MOUNT_HISTORY_FAILURE_CHILD).is_none() {
1419 let directory = tempfile::tempdir().unwrap();
1420 run_registration_child(
1421 MOUNT_HISTORY_FAILURE_CHILD,
1422 "a_failed_mount_history_write_does_not_fail_the_registered_session",
1423 directory.path(),
1424 );
1425 return;
1426 }
1427 let _writer = hel::hel_database::install_isolated_test_writer();
1429
1430 let mut controller = Controller {
1431 config: registration_config(),
1432 state: HelState::default(),
1433 };
1434 controller
1436 .register_session_with_resources(
1437 "codex",
1438 "project",
1439 "podman",
1440 "first",
1441 launch_options(Vec::new()),
1442 )
1443 .expect("a healthy store registers a session");
1444 let database = hel::hel_database::database_path();
1445 rusqlite::Connection::open(&database)
1446 .unwrap()
1447 .execute_batch("DROP TABLE mount_history")
1448 .unwrap();
1449
1450 let id = controller
1451 .register_session_with_resources(
1452 "codex",
1453 "project",
1454 "podman",
1455 "attached",
1456 launch_options(vec![AdditionalMount {
1457 source: PathBuf::from("/host/models"),
1458 destination: PathBuf::from("/mnt/models"),
1459 read_only: false,
1460 }]),
1461 )
1462 .expect("a suggestion list that cannot be written must not fail a registration");
1463
1464 let stored: i64 = rusqlite::Connection::open(&database)
1465 .unwrap()
1466 .query_row(
1467 "SELECT count(*) FROM sessions WHERE session_id = ?1",
1468 [&id],
1469 |row| row.get(0),
1470 )
1471 .unwrap();
1472 assert_eq!(stored, 1, "the registered session was not committed");
1473 assert!(
1474 controller.state.mount_history.is_empty(),
1475 "controller memory remembered mount sources the database never stored"
1476 );
1477 }
1478
1479 #[test]
1480 fn command_errors_report_the_root_cause_without_worker_wrappers() {
1481 let stderr = b"Error: restore target checkpoint failed with status 1: Error: restore repository \"bifrost\"\n\nCaused by:\n checkpoint base b41dc78 is absent from configured source\n repository may have moved\n";
1482
1483 assert_eq!(
1484 command_error_detail(stderr),
1485 "checkpoint base b41dc78 is absent from configured source\nrepository may have moved"
1486 );
1487 }
1488
1489 #[test]
1490 fn controller_store_lock_excludes_a_second_process_owner() {
1491 let directory = tempfile::tempdir().unwrap();
1492 let first = ControllerStoreGuard::acquire_at(directory.path()).unwrap();
1493 run_controller_lock_probe(directory.path(), true);
1494 drop(first);
1495 run_controller_lock_probe(directory.path(), false);
1496 }
1497 fn run_controller_lock_probe(directory: &Path, expect_locked: bool) {
1498 let output = std::process::Command::new(std::env::current_exe().unwrap())
1499 .args([
1500 "--exact",
1501 "hel_controller::tests::controller_store_lock_subprocess_probe",
1502 "--nocapture",
1503 ])
1504 .env("MJ_CONTROLLER_LOCK_PROBE", directory)
1505 .env(
1506 "MJ_CONTROLLER_LOCK_EXPECTED",
1507 if expect_locked { "locked" } else { "available" },
1508 )
1509 .output()
1510 .unwrap();
1511 assert!(
1512 output.status.success(),
1513 "controller lock subprocess failed:\nstdout:\n{}\nstderr:\n{}",
1514 String::from_utf8_lossy(&output.stdout),
1515 String::from_utf8_lossy(&output.stderr)
1516 );
1517 }
1518 #[test]
1519 fn controller_store_lock_subprocess_probe() {
1520 let Some(directory) = std::env::var_os("MJ_CONTROLLER_LOCK_PROBE") else {
1521 return;
1522 };
1523 let expected = std::env::var("MJ_CONTROLLER_LOCK_EXPECTED").unwrap();
1524 let acquired = ControllerStoreGuard::acquire_at(Path::new(&directory));
1525 match expected.as_str() {
1526 "locked" => {
1527 let error = acquired.expect_err("a second process acquired the controller store");
1528 assert!(error.to_string().contains("another Mjolnir controller"));
1529 }
1530 "available" => {
1531 acquired.expect("released controller store stayed locked");
1532 }
1533 value => panic!("unexpected lock probe expectation {value:?}"),
1534 }
1535 }
1536 #[test]
1537 fn local_mount_source_must_be_an_existing_directory() {
1538 let directory = tempfile::tempdir().unwrap();
1539 let file = directory.path().join("file");
1540 std::fs::write(&file, "not a directory").unwrap();
1541 let mut config = HelConfig::default();
1542 config.targets.insert(
1543 "local".into(),
1544 TargetTemplate::LocalPodman {
1545 container: ConfigContainer {
1546 image: "ubuntu:24.04".into(),
1547 pull_policy: Default::default(),
1548 platform: None,
1549 cpus: None,
1550 memory: None,
1551 environment: BTreeMap::new(),
1552 workspace_storage: Default::default(),
1553 },
1554 },
1555 );
1556 let controller = Controller {
1557 config,
1558 state: HelState::default(),
1559 };
1560
1561 assert!(
1562 controller
1563 .validate_mount_source("local", directory.path(), &ProcessExecutor)
1564 .is_ok()
1565 );
1566 for invalid in [file, directory.path().join("missing")] {
1567 let error = controller
1568 .validate_mount_source("local", &invalid, &ProcessExecutor)
1569 .unwrap_err();
1570 assert!(
1571 error
1572 .to_string()
1573 .contains("does not exist or is not a directory")
1574 );
1575 }
1576 }
1577}