1use std::path::{Path, PathBuf};
4
5use anyhow::{Context, Result, bail};
6
7use crate::hel_session_manager::StandaloneSession;
8use hel::hel_config::{AwsAddressSource, SshConnection, TargetTemplate};
9use hel::hel_state::{SessionRecord, SessionState, TargetLocator, normalize_session_title};
10use hel::hel_targets::{self, CommandExecutor, CommandOutput, CommandSpec, SshTarget};
11use hel::hel_worker_launch::WorkerOwnership;
12
13use super::backend::{ContainerOverrides, backend_locator, backend_target};
14use super::readiness::wait_for_native_session;
15use super::{Controller, backend_ssh, now, ssh_args_with_identity};
16
17#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
18pub struct RecoveryCandidate {
19 pub session_id: String,
20 pub target_template_id: String,
21 pub locator: TargetLocator,
22 pub ownership: Option<WorkerOwnership>,
23}
24
25#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
26pub struct RecoveryScan {
27 pub candidates: Vec<RecoveryCandidate>,
28 pub warnings: Vec<String>,
29}
30
31impl Controller {
32 pub fn scan_orphan_workers(&self, executor: &impl CommandExecutor) -> RecoveryScan {
36 let mut scan = RecoveryScan::default();
37 for (target_id, template) in &self.config.targets {
38 match scan_target_workers(target_id, template, executor) {
39 Ok(candidates) => {
40 scan.candidates
41 .extend(candidates.into_iter().filter(|candidate| {
42 !self.state.sessions.contains_key(&candidate.session_id)
43 }))
44 }
45 Err(error) => scan.warnings.push(format!("target {target_id}: {error:#}")),
46 }
47 }
48 scan.candidates.sort_by(|left, right| {
49 (&left.session_id, &left.target_template_id)
50 .cmp(&(&right.session_id, &right.target_template_id))
51 });
52 scan.candidates.dedup_by(|left, right| {
53 left.session_id == right.session_id
54 && left.target_template_id == right.target_template_id
55 });
56 scan
57 }
58
59 pub async fn adopt_orphan_worker(
60 &mut self,
61 session_id: &str,
62 target_id: &str,
63 profile_override: Option<&str>,
64 bundle_override: Option<&str>,
65 executor: &impl CommandExecutor,
66 ) -> Result<()> {
67 let (record, newly_adopted) = match self.state.sessions.get(session_id).cloned() {
68 Some(existing) if adoption_unfinished(&existing, target_id) => {
72 for (flag, requested, adopted) in [
73 ("profile", profile_override, existing.last_profile.as_str()),
74 ("bundle", bundle_override, existing.bundle_id.as_str()),
75 ] {
76 if let Some(requested) = requested
77 && requested != adopted
78 {
79 bail!(
80 "session {session_id} was already adopted with {flag} {adopted:?}; retry without --{flag}"
81 );
82 }
83 }
84 (existing, false)
85 }
86 Some(_) => bail!("session {session_id} is already tracked"),
87 None => {
88 let candidate = self
89 .scan_orphan_workers(executor)
90 .candidates
91 .into_iter()
92 .find(|candidate| {
93 candidate.session_id == session_id
94 && candidate.target_template_id == target_id
95 })
96 .with_context(|| {
97 format!("no managed orphan {session_id} was found on target {target_id:?}")
98 })?;
99 let profile_id = profile_override
100 .map(str::to_owned)
101 .or_else(|| {
102 candidate
103 .ownership
104 .as_ref()
105 .map(|marker| marker.profile_id.clone())
106 })
107 .context("orphan has no ownership marker; pass --profile")?;
108 let bundle_id = bundle_override
109 .map(str::to_owned)
110 .or_else(|| {
111 candidate
112 .ownership
113 .as_ref()
114 .map(|marker| marker.bundle_id.clone())
115 })
116 .context("orphan has no ownership marker; pass --bundle")?;
117 let profile = self
118 .config
119 .profiles
120 .get(&profile_id)
121 .with_context(|| format!("unknown profile {profile_id:?}"))?;
122 self.config
123 .bundles
124 .get(&bundle_id)
125 .with_context(|| format!("unknown bundle {bundle_id:?}"))?;
126 let workspace_id = resolve_recovery_workspace_id(
127 candidate
128 .ownership
129 .as_ref()
130 .map(|ownership| ownership.workspace_id.as_str())
131 .unwrap_or(hel::hel_workspace::DEFAULT_WORKSPACE_ID),
132 )?;
133 let record = adopted_session_record(
134 session_id,
135 target_id,
136 profile_id,
137 profile.kind,
138 bundle_id,
139 workspace_id,
140 candidate.locator,
141 );
142 (record, true)
143 }
144 };
145 let locator = record
146 .target
147 .as_ref()
148 .context("adopted session has no target locator")?;
149 let backend = backend_locator(locator, &record, &self.config)?;
150 let spec = hel_targets::reconnect_plan(&backend, session_id)?
151 .commands
152 .into_iter()
153 .next()
154 .context("reconnect plan is empty")?;
155 if newly_adopted {
156 hel::hel_database::save_session(&record)?;
161 self.state.sessions.insert(session_id.to_owned(), record);
162 }
163 match self.complete_adoption(session_id, &spec, executor).await {
164 Ok(()) => Ok(()),
165 Err(error) => Err(self.record_adoption_failure(session_id, error)),
169 }
170 }
171
172 async fn complete_adoption(
174 &mut self,
175 session_id: &str,
176 spec: &CommandSpec,
177 executor: &impl CommandExecutor,
178 ) -> Result<()> {
179 let mut relay = StandaloneSession::connect_command(spec, session_id)
180 .await
181 .context("orphan relay did not complete the v1 handshake")?;
182 let native_session_id = wait_for_native_session(&mut relay, executor).await?;
183 self.mark_worker_connected(session_id, Some(native_session_id))?;
184 if let Some(title) = relay
185 .snapshot()
186 .materialized
187 .session_title
188 .as_deref()
189 .and_then(normalize_session_title)
190 {
191 hel::hel_database::set_session_acp_title(session_id, Some(&title))?;
192 self.state
193 .sessions
194 .get_mut(session_id)
195 .expect("adopted session disappeared while saving its ACP title")
196 .acp_session_title = Some(title);
197 }
198 Ok(())
199 }
200
201 fn record_adoption_failure(&mut self, session_id: &str, error: anyhow::Error) -> anyhow::Error {
206 let Some(record) = self.state.sessions.get_mut(session_id) else {
207 return error;
208 };
209 record.updated_at = now();
210 record.last_error = Some(format!("orphan adoption failed: {error:#}"));
211 match self.persist_session_state(session_id) {
212 Ok(()) => error,
213 Err(persist_error) => error.context(format!(
214 "recorded the adoption failure in memory, but failed to persist it: {persist_error:#}"
215 )),
216 }
217 }
218
219 pub fn destroy_orphan_worker(
220 &self,
221 session_id: &str,
222 target_id: &str,
223 confirmation: &str,
224 executor: &impl CommandExecutor,
225 ) -> Result<()> {
226 if confirmation != session_id {
227 bail!("refusing destructive recovery: --confirm must exactly match the session ID");
228 }
229 let candidate = self
230 .scan_orphan_workers(executor)
231 .candidates
232 .into_iter()
233 .find(|candidate| {
234 candidate.session_id == session_id && candidate.target_template_id == target_id
235 })
236 .with_context(|| {
237 format!("no managed orphan {session_id} was found on target {target_id:?}")
238 })?;
239 let template = self.config.targets.get(target_id).unwrap();
240 let backend = recovery_backend_locator(template, &candidate.locator, session_id)?;
241 hel_targets::close_plan(&backend, session_id)?
242 .execute(executor)
243 .map(|_| ())
244 }
245}
246
247fn resolve_recovery_workspace_id(marked_workspace_id: &str) -> Result<String> {
251 if marked_workspace_id == hel::hel_workspace::DEFAULT_WORKSPACE_ID {
252 return Ok(marked_workspace_id.to_owned());
253 }
254 if hel::hel_database::list_workspaces()?
255 .into_iter()
256 .any(|workspace| workspace.id == marked_workspace_id)
257 {
258 return Ok(marked_workspace_id.to_owned());
259 }
260 Ok(hel::hel_database::create_or_get_workspace("Recovered")?.id)
261}
262
263fn adopted_session_record(
265 session_id: &str,
266 target_id: &str,
267 profile_id: String,
268 harness_kind: hel::hel_config::HarnessKind,
269 bundle_id: String,
270 workspace_id: String,
271 locator: TargetLocator,
272) -> SessionRecord {
273 let now = now();
274 SessionRecord {
275 workspace_id,
276 archived: false,
277 container_cpus: None,
278 container_memory: None,
279 id: session_id.to_owned(),
280 title: format!("Recovered {}", &session_id[..session_id.len().min(8)]),
281 harness_kind,
282 last_profile: profile_id,
283 bundle_id,
284 project_directory: None,
285 managed_worktree: None,
286 target_template_id: target_id.to_owned(),
287 resource_allocation: None,
288 additional_mounts: Vec::new(),
289 state: SessionState::Disconnected,
290 target: Some(locator),
291 native_session_id: None,
292 acp_session_title: None,
293 session_title_override: None,
294 created_at: now.clone(),
295 updated_at: now,
296 viewed_through_event_ordinal: 0,
297 draft_input: String::new(),
298 last_error: None,
299 last_checkpoint_error: None,
300 checkpoint: None,
301 }
302}
303
304fn adoption_unfinished(record: &SessionRecord, target_id: &str) -> bool {
309 record.state == SessionState::Disconnected
310 && record.native_session_id.is_none()
311 && record.target_template_id == target_id
312 && record.target.is_some()
313}
314
315fn scan_target_workers(
316 target_id: &str,
317 template: &TargetTemplate,
318 executor: &impl CommandExecutor,
319) -> Result<Vec<RecoveryCandidate>> {
320 let mut candidates = match template {
321 TargetTemplate::LocalBare => Vec::new(),
324 TargetTemplate::LocalPodman { .. } => scan_container_engine(
325 target_id,
326 template,
327 "podman",
328 vec![
329 "ps".into(),
330 "--all".into(),
331 "--filter".into(),
332 format!("label={}=true", hel_targets::MANAGED_LABEL),
333 "--format".into(),
334 "json".into(),
335 ],
336 executor,
337 )?,
338 TargetTemplate::LocalDocker { .. } => scan_container_engine(
339 target_id,
340 template,
341 "docker",
342 vec![
343 "ps".into(),
344 "--all".into(),
345 "--filter".into(),
346 format!("label={}=true", hel_targets::MANAGED_LABEL),
347 "--format".into(),
348 "json".into(),
349 ],
350 executor,
351 )?,
352 TargetTemplate::AppleContainer { .. } => scan_container_engine(
353 target_id,
354 template,
355 "container",
356 vec![
357 "list".into(),
358 "--all".into(),
359 "--format".into(),
360 "json".into(),
361 ],
362 executor,
363 )?,
364 TargetTemplate::SshPodman { ssh, .. } => {
365 let remote = hel_targets::join_remote_command(&[
366 "podman".into(),
367 "ps".into(),
368 "--all".into(),
369 "--filter".into(),
370 format!("label={}=true", hel_targets::MANAGED_LABEL),
371 "--format".into(),
372 "json".into(),
373 ]);
374 let output = execute_scan(
375 executor,
376 ssh_spec(ssh, [remote]),
377 "scan remote Podman workers",
378 )?;
379 candidates_from_container_json(target_id, template, &output.stdout)?
380 }
381 TargetTemplate::SshDocker { ssh, .. } => {
382 let remote = hel_targets::join_remote_command(&[
383 "docker".into(),
384 "ps".into(),
385 "--all".into(),
386 "--filter".into(),
387 format!("label={}=true", hel_targets::MANAGED_LABEL),
388 "--format".into(),
389 "json".into(),
390 ]);
391 let output = execute_scan(
392 executor,
393 ssh_spec(ssh, [remote]),
394 "scan remote Docker workers",
395 )?;
396 candidates_from_container_json(target_id, template, &output.stdout)?
397 }
398 TargetTemplate::AwsEc2 {
399 aws_profile,
400 region,
401 address_source,
402 ..
403 } => {
404 let profile = aws_profile.clone().unwrap_or_else(|| "default".into());
405 let output = execute_scan(
406 executor,
407 CommandSpec::new(
408 "aws",
409 [
410 "--profile".into(),
411 profile,
412 "--region".into(),
413 region.clone(),
414 "ec2".into(),
415 "describe-instances".into(),
416 "--filters".into(),
417 format!("Name=tag:{},Values=true", hel_targets::MANAGED_TAG),
418 "Name=instance-state-name,Values=pending,running,stopping,stopped".into(),
419 "--output".into(),
420 "json".into(),
421 ],
422 )
423 .purpose("scan managed EC2 workers"),
424 "scan managed EC2 workers",
425 )?;
426 candidates_from_aws_json(target_id, address_source.clone(), &output.stdout)?
427 }
428 TargetTemplate::SshBare { ssh, .. } => {
429 let output = execute_scan(
430 executor,
431 ssh_spec(
432 ssh,
433 [hel_targets::join_remote_command(&[
434 "find".into(),
435 ".local/share/hel/workers".into(),
436 "-mindepth".into(),
437 "2".into(),
438 "-maxdepth".into(),
439 "2".into(),
440 "-name".into(),
441 "ownership.json".into(),
442 "-print".into(),
443 ])],
444 ),
445 "scan bare SSH worker markers",
446 )?;
447 output
448 .stdout
449 .split(|byte| *byte == b'\n')
450 .filter_map(|line| {
451 let path = match std::str::from_utf8(line) {
452 Ok(path) => path.trim(),
453 Err(error) => {
454 tracing::debug!(%error, "recovery scan skipped a non-UTF-8 worker marker path");
455 return None;
456 }
457 };
458 let Some(session_id) = Path::new(path)
459 .parent()
460 .and_then(|parent| parent.file_name())
461 .and_then(|name| name.to_str())
462 else {
463 tracing::debug!(path, "recovery scan skipped a malformed worker marker path");
464 return None;
465 };
466 if let Err(error) = hel_targets::resource_name(session_id) {
467 tracing::debug!(session_id, %error, "recovery scan skipped an invalid session id");
468 return None;
469 }
470 let backend = match backend_target(template, None, ContainerOverrides::default()) {
471 Ok(backend) => backend,
472 Err(error) => {
473 tracing::debug!(session_id, %error, "recovery scan could not construct the target backend");
474 return None;
475 }
476 };
477 let workspace = match hel_targets::workspace_for(&backend, session_id) {
478 Ok(workspace) => workspace,
479 Err(error) => {
480 tracing::debug!(session_id, %error, "recovery scan could not derive the target workspace");
481 return None;
482 }
483 };
484 Some(RecoveryCandidate {
485 session_id: session_id.to_owned(),
486 target_template_id: target_id.to_owned(),
487 locator: TargetLocator::SshBare {
488 host: ssh.host.clone(),
489 workspace: PathBuf::from(workspace),
490 worker_id: None,
491 },
492 ownership: None,
493 })
494 })
495 .collect()
496 }
497 };
498 for candidate in &mut candidates {
499 candidate.ownership = read_recovery_ownership(template, candidate, executor);
500 }
501 Ok(candidates)
502}
503
504fn scan_container_engine(
505 target_id: &str,
506 template: &TargetTemplate,
507 engine: &str,
508 args: Vec<String>,
509 executor: &impl CommandExecutor,
510) -> Result<Vec<RecoveryCandidate>> {
511 let output = execute_scan(
512 executor,
513 CommandSpec::new(engine, args).purpose("scan managed container workers"),
514 "scan managed container workers",
515 )?;
516 candidates_from_container_json(target_id, template, &output.stdout)
517}
518
519fn candidates_from_container_json(
520 target_id: &str,
521 template: &TargetTemplate,
522 stdout: &[u8],
523) -> Result<Vec<RecoveryCandidate>> {
524 let sessions = managed_sessions_from_container_json(stdout)?;
525 Ok(sessions
526 .into_iter()
527 .filter_map(|session_id| {
528 let generated = match hel_targets::resource_name(&session_id) {
529 Ok(generated) => generated,
530 Err(error) => {
531 tracing::debug!(%session_id, %error, "recovery scan skipped an invalid managed session id");
532 return None;
533 }
534 };
535 let locator = match template {
536 TargetTemplate::LocalPodman { .. } => TargetLocator::LocalPodman {
537 container_id: generated,
538 workspace_storage: Default::default(),
539 },
540 TargetTemplate::LocalDocker { .. } => TargetLocator::LocalDocker {
541 container_id: generated,
542 },
543 TargetTemplate::AppleContainer { .. } => TargetLocator::AppleContainer {
544 container_id: generated,
545 },
546 TargetTemplate::SshPodman { ssh, .. } => TargetLocator::SshPodman {
547 host: ssh.host.clone(),
548 container_id: generated,
549 workspace_storage: Default::default(),
550 },
551 TargetTemplate::SshDocker { ssh, .. } => TargetLocator::SshDocker {
552 host: ssh.host.clone(),
553 container_id: generated,
554 },
555 _ => return None,
556 };
557 Some(RecoveryCandidate {
558 session_id,
559 target_template_id: target_id.to_owned(),
560 locator,
561 ownership: None,
562 })
563 })
564 .collect())
565}
566
567pub(super) fn managed_sessions_from_container_json(stdout: &[u8]) -> Result<Vec<String>> {
568 let values = serde_json::Deserializer::from_slice(stdout)
569 .into_iter::<serde_json::Value>()
570 .collect::<std::result::Result<Vec<_>, _>>()
571 .context("parse container list JSON")?;
572 let mut sessions = Vec::new();
573 for value in &values {
574 collect_managed_sessions(value, &mut sessions);
575 }
576 sessions.sort();
577 sessions.dedup();
578 Ok(sessions)
579}
580
581pub(super) fn collect_managed_sessions(value: &serde_json::Value, sessions: &mut Vec<String>) {
582 match value {
583 serde_json::Value::Array(values) => {
584 for value in values {
585 collect_managed_sessions(value, sessions);
586 }
587 }
588 serde_json::Value::Object(object) => {
589 for label_key in ["Labels", "labels"] {
590 if let Some(labels) = object.get(label_key) {
591 let managed = label_value(labels, hel_targets::MANAGED_LABEL)
592 .is_some_and(|value| value == "true");
593 if managed
594 && let Some(session) = label_value(labels, hel_targets::SESSION_LABEL)
595 {
596 sessions.push(session);
597 }
598 }
599 }
600 for value in object.values() {
601 collect_managed_sessions(value, sessions);
602 }
603 }
604 _ => {}
605 }
606}
607
608fn label_value(labels: &serde_json::Value, key: &str) -> Option<String> {
609 match labels {
610 serde_json::Value::Object(object) => object.get(key)?.as_str().map(str::to_owned),
611 serde_json::Value::String(text) => text
612 .split(',')
613 .find_map(|label| {
614 label
615 .trim()
616 .split_once('=')
617 .filter(|(name, _)| *name == key)
618 })
619 .map(|(_, value)| value.to_owned()),
620 _ => None,
621 }
622}
623
624fn candidates_from_aws_json(
625 target_id: &str,
626 address_source: AwsAddressSource,
627 stdout: &[u8],
628) -> Result<Vec<RecoveryCandidate>> {
629 let value: serde_json::Value =
630 serde_json::from_slice(stdout).context("parse AWS instance JSON")?;
631 let mut result = Vec::new();
632 let reservations = value
633 .get("Reservations")
634 .and_then(serde_json::Value::as_array)
635 .cloned()
636 .unwrap_or_default();
637 for instance in reservations.iter().flat_map(|reservation| {
638 reservation
639 .get("Instances")
640 .and_then(serde_json::Value::as_array)
641 .into_iter()
642 .flatten()
643 }) {
644 let tags = instance
645 .get("Tags")
646 .and_then(serde_json::Value::as_array)
647 .cloned()
648 .unwrap_or_default();
649 let tag = |key: &str| {
650 tags.iter()
651 .find(|tag| tag.get("Key").and_then(serde_json::Value::as_str) == Some(key))
652 .and_then(|tag| tag.get("Value"))
653 .and_then(serde_json::Value::as_str)
654 };
655 if tag(hel_targets::MANAGED_TAG) != Some("true") {
656 continue;
657 }
658 let Some(session_id) = tag(hel_targets::SESSION_TAG).map(str::to_owned) else {
659 continue;
660 };
661 hel_targets::resource_name(&session_id)?;
662 let instance_id = instance
663 .get("InstanceId")
664 .and_then(serde_json::Value::as_str)
665 .context("managed EC2 instance omitted InstanceId")?
666 .to_owned();
667 let field = match address_source {
668 AwsAddressSource::PublicDns => "PublicDnsName",
669 AwsAddressSource::PublicIp => "PublicIpAddress",
670 AwsAddressSource::PrivateDns => "PrivateDnsName",
671 AwsAddressSource::PrivateIp => "PrivateIpAddress",
672 };
673 let address = instance
674 .get(field)
675 .and_then(serde_json::Value::as_str)
676 .filter(|value| !value.is_empty())
677 .map(str::to_owned);
678 result.push(RecoveryCandidate {
679 session_id,
680 target_template_id: target_id.to_owned(),
681 locator: TargetLocator::AwsEc2 {
682 instance_id,
683 address,
684 },
685 ownership: None,
686 });
687 }
688 Ok(result)
689}
690
691fn execute_scan(
692 executor: &impl CommandExecutor,
693 command: CommandSpec,
694 operation: &str,
695) -> Result<CommandOutput> {
696 let output = executor.execute(&command)?;
697 if output.status != 0 {
698 bail!(
699 "{operation} failed with status {}: {}",
700 output.status,
701 String::from_utf8_lossy(&output.stderr).trim()
702 );
703 }
704 Ok(output)
705}
706
707fn ssh_spec(ssh: &SshConnection, remote: impl IntoIterator<Item = String>) -> CommandSpec {
708 let backend = backend_ssh(ssh);
709 let mut args = backend.ssh_args;
710 args.push(backend.destination);
711 args.extend(remote);
712 CommandSpec::new("ssh", args)
713}
714
715fn read_recovery_ownership(
716 template: &TargetTemplate,
717 candidate: &RecoveryCandidate,
718 executor: &impl CommandExecutor,
719) -> Option<WorkerOwnership> {
720 let backend =
721 match recovery_backend_locator(template, &candidate.locator, &candidate.session_id) {
722 Ok(backend) => backend,
723 Err(error) => {
724 tracing::debug!(
725 session_id = %candidate.session_id,
726 %error,
727 "could not construct a recovery ownership probe"
728 );
729 return None;
730 }
731 };
732 let root = match hel_targets::worker_root(&backend, &candidate.session_id) {
733 Ok(root) => root,
734 Err(error) => {
735 tracing::debug!(
736 session_id = %candidate.session_id,
737 %error,
738 "could not derive a recovery worker root"
739 );
740 return None;
741 }
742 };
743 let command = match hel_targets::command_on_locator(
744 &backend,
745 &candidate.session_id,
746 vec!["cat".into(), format!("{root}/ownership.json")],
747 "read worker ownership marker",
748 ) {
749 Ok(command) => command,
750 Err(error) => {
751 tracing::debug!(
752 session_id = %candidate.session_id,
753 %error,
754 "could not construct a recovery ownership command"
755 );
756 return None;
757 }
758 };
759 let output = match executor.execute(&command) {
760 Ok(output) => output,
761 Err(error) => {
762 tracing::debug!(
763 session_id = %candidate.session_id,
764 %error,
765 "could not read a recovery worker ownership marker"
766 );
767 return None;
768 }
769 };
770 if output.status != 0 {
771 tracing::debug!(
772 session_id = %candidate.session_id,
773 status = output.status,
774 "recovery worker ownership probe returned a failure"
775 );
776 return None;
777 }
778 let marker: WorkerOwnership = match serde_json::from_slice(&output.stdout) {
779 Ok(marker) => marker,
780 Err(error) => {
781 tracing::debug!(
782 session_id = %candidate.session_id,
783 %error,
784 "recovery worker ownership marker was not valid JSON"
785 );
786 return None;
787 }
788 };
789 if !(1..=WorkerOwnership::VERSION).contains(&marker.version)
790 || marker.session_id != candidate.session_id
791 || marker.target_template_id != candidate.target_template_id
792 {
793 tracing::debug!(
794 session_id = %candidate.session_id,
795 marker_session_id = %marker.session_id,
796 marker_target_template_id = %marker.target_template_id,
797 "recovery worker ownership marker did not match the candidate"
798 );
799 return None;
800 }
801 Some(marker)
802}
803
804fn recovery_backend_locator(
805 template: &TargetTemplate,
806 locator: &TargetLocator,
807 session_id: &str,
808) -> Result<hel_targets::TargetLocator> {
809 Ok(match (template, locator) {
810 (TargetTemplate::LocalBare, TargetLocator::LocalBare { worker_root }) => {
811 hel_targets::TargetLocator::LocalBare {
812 worker_root: worker_root.to_string_lossy().into_owned(),
813 }
814 }
815 (TargetTemplate::LocalPodman { .. }, TargetLocator::LocalPodman { container_id, .. }) => {
816 hel_targets::TargetLocator::LocalPodman {
817 container_id: container_id.clone(),
818 workspace_storage: Default::default(),
819 }
820 }
821 (TargetTemplate::LocalDocker { .. }, TargetLocator::LocalDocker { container_id }) => {
822 hel_targets::TargetLocator::LocalDocker {
823 container_id: container_id.clone(),
824 }
825 }
826 (TargetTemplate::AppleContainer { .. }, TargetLocator::AppleContainer { container_id }) => {
827 hel_targets::TargetLocator::AppleContainer {
828 container_id: container_id.clone(),
829 }
830 }
831 (TargetTemplate::SshPodman { ssh, .. }, TargetLocator::SshPodman { container_id, .. }) => {
832 hel_targets::TargetLocator::SshPodman {
833 ssh: backend_ssh(ssh),
834 container_id: container_id.clone(),
835 workspace_storage: Default::default(),
836 }
837 }
838 (
839 TargetTemplate::SshDocker { ssh, .. },
840 TargetLocator::SshDocker { host, container_id },
841 ) => {
842 if host != &ssh.host {
843 bail!("recovery SSH Docker host does not match target template")
844 }
845 hel_targets::TargetLocator::SshDocker {
846 ssh: backend_ssh(ssh),
847 container_id: container_id.clone(),
848 }
849 }
850 (TargetTemplate::SshBare { ssh, .. }, TargetLocator::SshBare { workspace, .. }) => {
851 hel_targets::TargetLocator::SshBare {
852 ssh: backend_ssh(ssh),
853 workspace: workspace.to_string_lossy().into_owned(),
854 }
855 }
856 (
857 TargetTemplate::AwsEc2 {
858 aws_profile,
859 region,
860 ssh_user,
861 identity_file,
862 ssh_args,
863 ..
864 },
865 TargetLocator::AwsEc2 {
866 instance_id,
867 address,
868 },
869 ) => hel_targets::TargetLocator::AwsEc2 {
870 profile: aws_profile.clone().unwrap_or_else(|| "default".into()),
871 region: region.clone(),
872 instance_id: instance_id.clone(),
873 ssh: SshTarget {
874 destination: format!(
875 "{ssh_user}@{}",
876 address.as_deref().unwrap_or("unavailable.invalid")
877 ),
878 ssh_args: ssh_args_with_identity(ssh_args, identity_file.as_deref()),
879 },
880 workspace: format!(".local/share/hel/workspaces/{session_id}"),
881 },
882 _ => bail!("recovery target locator does not match target template"),
883 })
884}
885
886#[cfg(test)]
887mod tests {
888 use std::collections::BTreeMap;
889
890 use hel::hel_config::{
891 AwsAddressSource, ContainerTemplate as ConfigContainer, HarnessKind, HelConfig,
892 TargetTemplate,
893 };
894 use hel::hel_state::{HelState, TargetLocator};
895 use hel::hel_targets::ProcessExecutor;
896
897 use super::*;
898
899 const FAILED_ADOPTION_CHILD: &str = "MJ_TEST_FAILED_ADOPTION_CHILD";
900
901 #[tokio::test]
902 async fn a_failed_adoption_records_the_failure_and_stays_retryable() {
903 if std::env::var_os(FAILED_ADOPTION_CHILD).is_none() {
906 let directory = tempfile::tempdir().unwrap();
907 let output = std::process::Command::new(std::env::current_exe().unwrap())
908 .args([
909 "--exact",
910 "hel_controller::recovery_scan::tests::\
911 a_failed_adoption_records_the_failure_and_stays_retryable",
912 "--nocapture",
913 ])
914 .env(FAILED_ADOPTION_CHILD, "1")
915 .env("MJ_DATA_DIR", directory.path())
916 .output()
917 .unwrap();
918 assert!(
919 output.status.success(),
920 "isolated adoption retry test failed\nstdout:\n{}\nstderr:\n{}",
921 String::from_utf8_lossy(&output.stdout),
922 String::from_utf8_lossy(&output.stderr)
923 );
924 return;
925 }
926 let _writer = hel::hel_database::install_isolated_test_writer();
928
929 let session_id = "0123456789abcdef0123456789abcdef";
930 let workers = tempfile::tempdir().unwrap();
931 let record = adopted_session_record(
934 session_id,
935 "local-bare",
936 "codex".into(),
937 HarnessKind::Codex,
938 "project".into(),
939 hel::hel_workspace::DEFAULT_WORKSPACE_ID.to_owned(),
940 TargetLocator::LocalBare {
941 worker_root: workers.path().join(session_id),
942 },
943 );
944 assert!(
945 adoption_unfinished(&record, "local-bare"),
946 "the record adoption commits must be the record adoption can retry"
947 );
948 hel::hel_database::save_session(&record).unwrap();
949 let mut config = HelConfig::default();
950 config
951 .targets
952 .insert("local-bare".into(), TargetTemplate::LocalBare);
953 let mut state = HelState::default();
954 state.sessions.insert(session_id.to_owned(), record);
955 let mut controller = Controller { config, state };
956
957 let failure = controller
958 .adopt_orphan_worker(session_id, "local-bare", None, None, &ProcessExecutor)
959 .await
960 .expect_err("a worker root without a worker cannot complete the handshake");
961 assert!(
962 format!("{failure:#}").contains("orphan relay"),
963 "unexpected failure: {failure:#}"
964 );
965 let recorded = controller.state.sessions[session_id]
966 .last_error
967 .clone()
968 .expect("the failed handshake was recorded on the session");
969 assert!(
970 recorded.contains("orphan adoption failed"),
971 "unexpected recorded failure: {recorded}"
972 );
973 assert_eq!(
974 controller.state.sessions[session_id].state,
975 SessionState::Disconnected
976 );
977 let stored = hel::hel_database::load_state().unwrap();
978 assert_eq!(
979 stored.sessions[session_id].last_error.as_deref(),
980 Some(recorded.as_str()),
981 "the adoption failure was not persisted"
982 );
983
984 let retry = controller
985 .adopt_orphan_worker(session_id, "local-bare", None, None, &ProcessExecutor)
986 .await
987 .expect_err("the worker is still unreachable");
988 let retry = format!("{retry:#}");
989 assert!(
990 retry.contains("orphan relay"),
991 "adoption did not retry the handshake: {retry}"
992 );
993 assert!(
994 !retry.contains("already tracked"),
995 "a session adoption never finished blocked its own retry: {retry}"
996 );
997 }
998
999 const RECOVERY_WORKSPACE_CHILD: &str = "MJ_TEST_RECOVERY_WORKSPACE_CHILD";
1000
1001 #[tokio::test]
1002 async fn orphan_workspace_ids_are_reconciled_before_adoption_persistence() {
1003 if std::env::var_os(RECOVERY_WORKSPACE_CHILD).is_none() {
1004 let directory = tempfile::tempdir().unwrap();
1005 let test = "orphan_workspace_ids_are_reconciled_before_adoption_persistence";
1006 let mut command = std::process::Command::new(std::env::current_exe().unwrap());
1007 command
1008 .args([
1009 "--exact",
1010 &format!("hel_controller::recovery_scan::tests::{test}"),
1011 "--nocapture",
1012 ])
1013 .env(RECOVERY_WORKSPACE_CHILD, "1")
1014 .env("MJ_DATA_DIR", directory.path());
1015 let output = hel::hel_subprocess::run_with_input(&mut command, &[]).unwrap();
1016 assert!(
1017 output.status.success(),
1018 "isolated recovery workspace test failed\nstdout:\n{}\nstderr:\n{}",
1019 String::from_utf8_lossy(&output.stdout),
1020 String::from_utf8_lossy(&output.stderr)
1021 );
1022 return;
1023 }
1024
1025 let _writer = hel::hel_database::install_isolated_test_writer();
1026 let default =
1027 resolve_recovery_workspace_id(hel::hel_workspace::DEFAULT_WORKSPACE_ID).unwrap();
1028 assert_eq!(default, hel::hel_workspace::DEFAULT_WORKSPACE_ID);
1029
1030 let known = hel::hel_database::create_or_get_workspace("Known").unwrap();
1031 assert_eq!(resolve_recovery_workspace_id(&known.id).unwrap(), known.id);
1032
1033 let recovered = resolve_recovery_workspace_id("workspace-from-old-controller").unwrap();
1034 let repeated = resolve_recovery_workspace_id("another-old-workspace").unwrap();
1035 assert_eq!(repeated, recovered);
1036 assert_eq!(
1037 hel::hel_database::list_workspaces()
1038 .unwrap()
1039 .iter()
1040 .filter(|workspace| workspace.name == "Recovered")
1041 .count(),
1042 1
1043 );
1044
1045 let session_id = "0123456789abcdef0123456789abcdef";
1046 let workers = tempfile::tempdir().unwrap();
1047 let record = adopted_session_record(
1048 session_id,
1049 "local-bare",
1050 "codex".into(),
1051 HarnessKind::Codex,
1052 "project".into(),
1053 recovered.clone(),
1054 TargetLocator::LocalBare {
1055 worker_root: workers.path().join(session_id),
1056 },
1057 );
1058 hel::hel_database::save_session(&record).unwrap();
1059 assert_eq!(
1060 hel::hel_database::load_state().unwrap().sessions[session_id].workspace_id,
1061 recovered
1062 );
1063
1064 let mut state = HelState::default();
1068 state.sessions.insert(session_id.to_owned(), record);
1069 let mut config = HelConfig::default();
1070 config
1071 .targets
1072 .insert("local-bare".into(), TargetTemplate::LocalBare);
1073 let mut controller = Controller { config, state };
1074 let failure = controller
1075 .adopt_orphan_worker(session_id, "local-bare", None, None, &ProcessExecutor)
1076 .await
1077 .expect_err("a worker root without a worker cannot complete the handshake");
1078 assert!(
1079 format!("{failure:#}").contains("orphan relay"),
1080 "unexpected failure: {failure:#}"
1081 );
1082 let stored = hel::hel_database::load_state().unwrap();
1083 assert_eq!(stored.sessions[session_id].workspace_id, recovered);
1084 assert!(
1085 stored.sessions[session_id]
1086 .last_error
1087 .as_deref()
1088 .is_some_and(|error| error.contains("orphan adoption failed"))
1089 );
1090 }
1091
1092 #[test]
1093 fn a_session_that_completed_its_handshake_is_not_adoptable_again() {
1094 let mut record = adopted_session_record(
1095 "0123456789abcdef0123456789abcdef",
1096 "local-bare",
1097 "codex".into(),
1098 HarnessKind::Codex,
1099 "project".into(),
1100 hel::hel_workspace::DEFAULT_WORKSPACE_ID.to_owned(),
1101 TargetLocator::LocalBare {
1102 worker_root: std::path::PathBuf::from("/workers/0123456789abcdef0123456789abcdef"),
1103 },
1104 );
1105 record.native_session_id = Some("native-session".into());
1106 assert!(!adoption_unfinished(&record, "local-bare"));
1107
1108 record.native_session_id = None;
1109 assert!(
1110 !adoption_unfinished(&record, "other-target"),
1111 "a record adopted onto another target is not this target's retry"
1112 );
1113 }
1114
1115 #[test]
1116 fn recovery_container_scan_requires_both_managed_and_session_labels() {
1117 let template = TargetTemplate::LocalPodman {
1118 container: ConfigContainer {
1119 image: "ignored".into(),
1120 pull_policy: Default::default(),
1121 platform: None,
1122 cpus: None,
1123 memory: None,
1124 environment: BTreeMap::new(),
1125 workspace_storage: Default::default(),
1126 },
1127 };
1128 let json = serde_json::json!([
1129 {"Labels": {"dev.mj.managed": "true", "dev.mj.session": "0123456789abcdef0123456789abcdef"}},
1130 {"Labels": {"dev.mj.managed": "false", "dev.mj.session": "not-owned"}},
1131 {"configuration": {"labels": "dev.mj.managed=true,dev.mj.session=abcdef0123456789abcdef0123456789"}}
1132 ]);
1133 let candidates = candidates_from_container_json(
1134 "local",
1135 &template,
1136 serde_json::to_string(&json).unwrap().as_bytes(),
1137 )
1138 .unwrap();
1139 assert_eq!(candidates.len(), 2);
1140 assert_eq!(candidates[0].session_id, "0123456789abcdef0123456789abcdef");
1141 }
1142
1143 #[test]
1144 fn recovery_docker_scan_accepts_json_lines_and_builds_a_docker_locator() {
1145 let template = TargetTemplate::LocalDocker {
1146 container: ConfigContainer {
1147 image: "ignored".into(),
1148 pull_policy: Default::default(),
1149 platform: None,
1150 cpus: None,
1151 memory: None,
1152 environment: BTreeMap::new(),
1153 workspace_storage: Default::default(),
1154 },
1155 };
1156 let session = "0123456789abcdef0123456789abcdef";
1157 let output = format!(
1158 "{{\"Labels\":\"dev.mj.managed=true,dev.mj.session={session}\"}}\n{{\"Labels\":\"dev.mj.managed=false,dev.mj.session=ignored\"}}\n"
1159 );
1160
1161 let candidates =
1162 candidates_from_container_json("docker", &template, output.as_bytes()).unwrap();
1163
1164 assert_eq!(candidates.len(), 1);
1165 assert_eq!(candidates[0].session_id, session);
1166 assert!(matches!(
1167 &candidates[0].locator,
1168 TargetLocator::LocalDocker { container_id }
1169 if container_id == &hel_targets::resource_name(session).unwrap()
1170 ));
1171 }
1172
1173 #[test]
1174 fn recovery_aws_scan_uses_exact_tagged_instance_and_address() {
1175 let json = serde_json::json!({"Reservations": [{"Instances": [{
1176 "InstanceId": "i-exact",
1177 "PrivateIpAddress": "10.0.0.7",
1178 "Tags": [
1179 {"Key": "dev.mj.managed", "Value": "true"},
1180 {"Key": "dev.mj.session", "Value": "0123456789abcdef0123456789abcdef"}
1181 ]
1182 }]}]});
1183 let candidates = candidates_from_aws_json(
1184 "aws",
1185 AwsAddressSource::PrivateIp,
1186 serde_json::to_string(&json).unwrap().as_bytes(),
1187 )
1188 .unwrap();
1189 assert!(matches!(
1190 &candidates[0].locator,
1191 TargetLocator::AwsEc2 { instance_id, address }
1192 if instance_id == "i-exact" && address.as_deref() == Some("10.0.0.7")
1193 ));
1194 }
1195}