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 record = adopted_session_record(
127 session_id,
128 target_id,
129 profile_id,
130 profile.kind,
131 bundle_id,
132 candidate
133 .ownership
134 .as_ref()
135 .map(|ownership| ownership.workspace_id.clone())
136 .unwrap_or_else(|| hel::hel_workspace::DEFAULT_WORKSPACE_ID.to_owned()),
137 candidate.locator,
138 );
139 (record, true)
140 }
141 };
142 let locator = record
143 .target
144 .as_ref()
145 .context("adopted session has no target locator")?;
146 let backend = backend_locator(locator, &record, &self.config)?;
147 let spec = hel_targets::reconnect_plan(&backend, session_id)?
148 .commands
149 .into_iter()
150 .next()
151 .context("reconnect plan is empty")?;
152 if newly_adopted {
153 hel::hel_database::save_session(&record)?;
158 self.state.sessions.insert(session_id.to_owned(), record);
159 }
160 match self.complete_adoption(session_id, &spec, executor).await {
161 Ok(()) => Ok(()),
162 Err(error) => Err(self.record_adoption_failure(session_id, error)),
166 }
167 }
168
169 async fn complete_adoption(
171 &mut self,
172 session_id: &str,
173 spec: &CommandSpec,
174 executor: &impl CommandExecutor,
175 ) -> Result<()> {
176 let mut relay = StandaloneSession::connect_command(spec, session_id)
177 .await
178 .context("orphan relay did not complete the v1 handshake")?;
179 let native_session_id = wait_for_native_session(&mut relay, executor).await?;
180 self.mark_worker_connected(session_id, Some(native_session_id))?;
181 if let Some(title) = relay
182 .snapshot()
183 .materialized
184 .session_title
185 .as_deref()
186 .and_then(normalize_session_title)
187 {
188 hel::hel_database::set_session_acp_title(session_id, Some(&title))?;
189 self.state
190 .sessions
191 .get_mut(session_id)
192 .expect("adopted session disappeared while saving its ACP title")
193 .acp_session_title = Some(title);
194 }
195 Ok(())
196 }
197
198 fn record_adoption_failure(&mut self, session_id: &str, error: anyhow::Error) -> anyhow::Error {
203 let Some(record) = self.state.sessions.get_mut(session_id) else {
204 return error;
205 };
206 record.updated_at = now();
207 record.last_error = Some(format!("orphan adoption failed: {error:#}"));
208 match self.persist_session_state(session_id) {
209 Ok(()) => error,
210 Err(persist_error) => error.context(format!(
211 "recorded the adoption failure in memory, but failed to persist it: {persist_error:#}"
212 )),
213 }
214 }
215
216 pub fn destroy_orphan_worker(
217 &self,
218 session_id: &str,
219 target_id: &str,
220 confirmation: &str,
221 executor: &impl CommandExecutor,
222 ) -> Result<()> {
223 if confirmation != session_id {
224 bail!("refusing destructive recovery: --confirm must exactly match the session ID");
225 }
226 let candidate = self
227 .scan_orphan_workers(executor)
228 .candidates
229 .into_iter()
230 .find(|candidate| {
231 candidate.session_id == session_id && candidate.target_template_id == target_id
232 })
233 .with_context(|| {
234 format!("no managed orphan {session_id} was found on target {target_id:?}")
235 })?;
236 let template = self.config.targets.get(target_id).unwrap();
237 let backend = recovery_backend_locator(template, &candidate.locator, session_id)?;
238 hel_targets::close_plan(&backend, session_id)?
239 .execute(executor)
240 .map(|_| ())
241 }
242}
243
244fn adopted_session_record(
246 session_id: &str,
247 target_id: &str,
248 profile_id: String,
249 harness_kind: hel::hel_config::HarnessKind,
250 bundle_id: String,
251 workspace_id: String,
252 locator: TargetLocator,
253) -> SessionRecord {
254 let now = now();
255 SessionRecord {
256 workspace_id,
257 archived: false,
258 container_cpus: None,
259 container_memory: None,
260 id: session_id.to_owned(),
261 title: format!("Recovered {}", &session_id[..session_id.len().min(8)]),
262 harness_kind,
263 last_profile: profile_id,
264 bundle_id,
265 project_directory: None,
266 managed_worktree: None,
267 target_template_id: target_id.to_owned(),
268 resource_allocation: None,
269 additional_mounts: Vec::new(),
270 state: SessionState::Disconnected,
271 target: Some(locator),
272 native_session_id: None,
273 acp_session_title: None,
274 session_title_override: None,
275 created_at: now.clone(),
276 updated_at: now,
277 viewed_through_event_ordinal: 0,
278 draft_input: String::new(),
279 last_error: None,
280 last_checkpoint_error: None,
281 checkpoint: None,
282 }
283}
284
285fn adoption_unfinished(record: &SessionRecord, target_id: &str) -> bool {
290 record.state == SessionState::Disconnected
291 && record.native_session_id.is_none()
292 && record.target_template_id == target_id
293 && record.target.is_some()
294}
295
296fn scan_target_workers(
297 target_id: &str,
298 template: &TargetTemplate,
299 executor: &impl CommandExecutor,
300) -> Result<Vec<RecoveryCandidate>> {
301 let mut candidates = match template {
302 TargetTemplate::LocalBare => Vec::new(),
305 TargetTemplate::LocalPodman { .. } => scan_container_engine(
306 target_id,
307 template,
308 "podman",
309 vec![
310 "ps".into(),
311 "--all".into(),
312 "--filter".into(),
313 format!("label={}=true", hel_targets::MANAGED_LABEL),
314 "--format".into(),
315 "json".into(),
316 ],
317 executor,
318 )?,
319 TargetTemplate::LocalDocker { .. } => scan_container_engine(
320 target_id,
321 template,
322 "docker",
323 vec![
324 "ps".into(),
325 "--all".into(),
326 "--filter".into(),
327 format!("label={}=true", hel_targets::MANAGED_LABEL),
328 "--format".into(),
329 "json".into(),
330 ],
331 executor,
332 )?,
333 TargetTemplate::AppleContainer { .. } => scan_container_engine(
334 target_id,
335 template,
336 "container",
337 vec![
338 "list".into(),
339 "--all".into(),
340 "--format".into(),
341 "json".into(),
342 ],
343 executor,
344 )?,
345 TargetTemplate::SshPodman { ssh, .. } => {
346 let remote = hel_targets::join_remote_command(&[
347 "podman".into(),
348 "ps".into(),
349 "--all".into(),
350 "--filter".into(),
351 format!("label={}=true", hel_targets::MANAGED_LABEL),
352 "--format".into(),
353 "json".into(),
354 ]);
355 let output = execute_scan(
356 executor,
357 ssh_spec(ssh, [remote]),
358 "scan remote Podman workers",
359 )?;
360 candidates_from_container_json(target_id, template, &output.stdout)?
361 }
362 TargetTemplate::AwsEc2 {
363 aws_profile,
364 region,
365 address_source,
366 ..
367 } => {
368 let profile = aws_profile.clone().unwrap_or_else(|| "default".into());
369 let output = execute_scan(
370 executor,
371 CommandSpec::new(
372 "aws",
373 [
374 "--profile".into(),
375 profile,
376 "--region".into(),
377 region.clone(),
378 "ec2".into(),
379 "describe-instances".into(),
380 "--filters".into(),
381 format!("Name=tag:{},Values=true", hel_targets::MANAGED_TAG),
382 "Name=instance-state-name,Values=pending,running,stopping,stopped".into(),
383 "--output".into(),
384 "json".into(),
385 ],
386 )
387 .purpose("scan managed EC2 workers"),
388 "scan managed EC2 workers",
389 )?;
390 candidates_from_aws_json(target_id, address_source.clone(), &output.stdout)?
391 }
392 TargetTemplate::SshBare { ssh, .. } => {
393 let output = execute_scan(
394 executor,
395 ssh_spec(
396 ssh,
397 [hel_targets::join_remote_command(&[
398 "find".into(),
399 ".local/share/hel/workers".into(),
400 "-mindepth".into(),
401 "2".into(),
402 "-maxdepth".into(),
403 "2".into(),
404 "-name".into(),
405 "ownership.json".into(),
406 "-print".into(),
407 ])],
408 ),
409 "scan bare SSH worker markers",
410 )?;
411 output
412 .stdout
413 .split(|byte| *byte == b'\n')
414 .filter_map(|line| {
415 let path = match std::str::from_utf8(line) {
416 Ok(path) => path.trim(),
417 Err(error) => {
418 tracing::debug!(%error, "recovery scan skipped a non-UTF-8 worker marker path");
419 return None;
420 }
421 };
422 let Some(session_id) = Path::new(path)
423 .parent()
424 .and_then(|parent| parent.file_name())
425 .and_then(|name| name.to_str())
426 else {
427 tracing::debug!(path, "recovery scan skipped a malformed worker marker path");
428 return None;
429 };
430 if let Err(error) = hel_targets::resource_name(session_id) {
431 tracing::debug!(session_id, %error, "recovery scan skipped an invalid session id");
432 return None;
433 }
434 let backend = match backend_target(template, None, ContainerOverrides::default()) {
435 Ok(backend) => backend,
436 Err(error) => {
437 tracing::debug!(session_id, %error, "recovery scan could not construct the target backend");
438 return None;
439 }
440 };
441 let workspace = match hel_targets::workspace_for(&backend, session_id) {
442 Ok(workspace) => workspace,
443 Err(error) => {
444 tracing::debug!(session_id, %error, "recovery scan could not derive the target workspace");
445 return None;
446 }
447 };
448 Some(RecoveryCandidate {
449 session_id: session_id.to_owned(),
450 target_template_id: target_id.to_owned(),
451 locator: TargetLocator::SshBare {
452 host: ssh.host.clone(),
453 workspace: PathBuf::from(workspace),
454 worker_id: None,
455 },
456 ownership: None,
457 })
458 })
459 .collect()
460 }
461 };
462 for candidate in &mut candidates {
463 candidate.ownership = read_recovery_ownership(template, candidate, executor);
464 }
465 Ok(candidates)
466}
467
468fn scan_container_engine(
469 target_id: &str,
470 template: &TargetTemplate,
471 engine: &str,
472 args: Vec<String>,
473 executor: &impl CommandExecutor,
474) -> Result<Vec<RecoveryCandidate>> {
475 let output = execute_scan(
476 executor,
477 CommandSpec::new(engine, args).purpose("scan managed container workers"),
478 "scan managed container workers",
479 )?;
480 candidates_from_container_json(target_id, template, &output.stdout)
481}
482
483fn candidates_from_container_json(
484 target_id: &str,
485 template: &TargetTemplate,
486 stdout: &[u8],
487) -> Result<Vec<RecoveryCandidate>> {
488 let sessions = managed_sessions_from_container_json(stdout)?;
489 Ok(sessions
490 .into_iter()
491 .filter_map(|session_id| {
492 let generated = match hel_targets::resource_name(&session_id) {
493 Ok(generated) => generated,
494 Err(error) => {
495 tracing::debug!(%session_id, %error, "recovery scan skipped an invalid managed session id");
496 return None;
497 }
498 };
499 let locator = match template {
500 TargetTemplate::LocalPodman { .. } => TargetLocator::LocalPodman {
501 container_id: generated,
502 workspace_storage: Default::default(),
503 },
504 TargetTemplate::LocalDocker { .. } => TargetLocator::LocalDocker {
505 container_id: generated,
506 },
507 TargetTemplate::AppleContainer { .. } => TargetLocator::AppleContainer {
508 container_id: generated,
509 },
510 TargetTemplate::SshPodman { ssh, .. } => TargetLocator::SshPodman {
511 host: ssh.host.clone(),
512 container_id: generated,
513 workspace_storage: Default::default(),
514 },
515 _ => return None,
516 };
517 Some(RecoveryCandidate {
518 session_id,
519 target_template_id: target_id.to_owned(),
520 locator,
521 ownership: None,
522 })
523 })
524 .collect())
525}
526
527pub(super) fn managed_sessions_from_container_json(stdout: &[u8]) -> Result<Vec<String>> {
528 let values = serde_json::Deserializer::from_slice(stdout)
529 .into_iter::<serde_json::Value>()
530 .collect::<std::result::Result<Vec<_>, _>>()
531 .context("parse container list JSON")?;
532 let mut sessions = Vec::new();
533 for value in &values {
534 collect_managed_sessions(value, &mut sessions);
535 }
536 sessions.sort();
537 sessions.dedup();
538 Ok(sessions)
539}
540
541pub(super) fn collect_managed_sessions(value: &serde_json::Value, sessions: &mut Vec<String>) {
542 match value {
543 serde_json::Value::Array(values) => {
544 for value in values {
545 collect_managed_sessions(value, sessions);
546 }
547 }
548 serde_json::Value::Object(object) => {
549 for label_key in ["Labels", "labels"] {
550 if let Some(labels) = object.get(label_key) {
551 let managed = label_value(labels, hel_targets::MANAGED_LABEL)
552 .is_some_and(|value| value == "true");
553 if managed
554 && let Some(session) = label_value(labels, hel_targets::SESSION_LABEL)
555 {
556 sessions.push(session);
557 }
558 }
559 }
560 for value in object.values() {
561 collect_managed_sessions(value, sessions);
562 }
563 }
564 _ => {}
565 }
566}
567
568fn label_value(labels: &serde_json::Value, key: &str) -> Option<String> {
569 match labels {
570 serde_json::Value::Object(object) => object.get(key)?.as_str().map(str::to_owned),
571 serde_json::Value::String(text) => text
572 .split(',')
573 .find_map(|label| {
574 label
575 .trim()
576 .split_once('=')
577 .filter(|(name, _)| *name == key)
578 })
579 .map(|(_, value)| value.to_owned()),
580 _ => None,
581 }
582}
583
584fn candidates_from_aws_json(
585 target_id: &str,
586 address_source: AwsAddressSource,
587 stdout: &[u8],
588) -> Result<Vec<RecoveryCandidate>> {
589 let value: serde_json::Value =
590 serde_json::from_slice(stdout).context("parse AWS instance JSON")?;
591 let mut result = Vec::new();
592 let reservations = value
593 .get("Reservations")
594 .and_then(serde_json::Value::as_array)
595 .cloned()
596 .unwrap_or_default();
597 for instance in reservations.iter().flat_map(|reservation| {
598 reservation
599 .get("Instances")
600 .and_then(serde_json::Value::as_array)
601 .into_iter()
602 .flatten()
603 }) {
604 let tags = instance
605 .get("Tags")
606 .and_then(serde_json::Value::as_array)
607 .cloned()
608 .unwrap_or_default();
609 let tag = |key: &str| {
610 tags.iter()
611 .find(|tag| tag.get("Key").and_then(serde_json::Value::as_str) == Some(key))
612 .and_then(|tag| tag.get("Value"))
613 .and_then(serde_json::Value::as_str)
614 };
615 if tag(hel_targets::MANAGED_TAG) != Some("true") {
616 continue;
617 }
618 let Some(session_id) = tag(hel_targets::SESSION_TAG).map(str::to_owned) else {
619 continue;
620 };
621 hel_targets::resource_name(&session_id)?;
622 let instance_id = instance
623 .get("InstanceId")
624 .and_then(serde_json::Value::as_str)
625 .context("managed EC2 instance omitted InstanceId")?
626 .to_owned();
627 let field = match address_source {
628 AwsAddressSource::PublicDns => "PublicDnsName",
629 AwsAddressSource::PublicIp => "PublicIpAddress",
630 AwsAddressSource::PrivateDns => "PrivateDnsName",
631 AwsAddressSource::PrivateIp => "PrivateIpAddress",
632 };
633 let address = instance
634 .get(field)
635 .and_then(serde_json::Value::as_str)
636 .filter(|value| !value.is_empty())
637 .map(str::to_owned);
638 result.push(RecoveryCandidate {
639 session_id,
640 target_template_id: target_id.to_owned(),
641 locator: TargetLocator::AwsEc2 {
642 instance_id,
643 address,
644 },
645 ownership: None,
646 });
647 }
648 Ok(result)
649}
650
651fn execute_scan(
652 executor: &impl CommandExecutor,
653 command: CommandSpec,
654 operation: &str,
655) -> Result<CommandOutput> {
656 let output = executor.execute(&command)?;
657 if output.status != 0 {
658 bail!(
659 "{operation} failed with status {}: {}",
660 output.status,
661 String::from_utf8_lossy(&output.stderr).trim()
662 );
663 }
664 Ok(output)
665}
666
667fn ssh_spec(ssh: &SshConnection, remote: impl IntoIterator<Item = String>) -> CommandSpec {
668 let backend = backend_ssh(ssh);
669 let mut args = backend.ssh_args;
670 args.push(backend.destination);
671 args.extend(remote);
672 CommandSpec::new("ssh", args)
673}
674
675fn read_recovery_ownership(
676 template: &TargetTemplate,
677 candidate: &RecoveryCandidate,
678 executor: &impl CommandExecutor,
679) -> Option<WorkerOwnership> {
680 let backend =
681 match recovery_backend_locator(template, &candidate.locator, &candidate.session_id) {
682 Ok(backend) => backend,
683 Err(error) => {
684 tracing::debug!(
685 session_id = %candidate.session_id,
686 %error,
687 "could not construct a recovery ownership probe"
688 );
689 return None;
690 }
691 };
692 let root = match hel_targets::worker_root(&backend, &candidate.session_id) {
693 Ok(root) => root,
694 Err(error) => {
695 tracing::debug!(
696 session_id = %candidate.session_id,
697 %error,
698 "could not derive a recovery worker root"
699 );
700 return None;
701 }
702 };
703 let command = match hel_targets::command_on_locator(
704 &backend,
705 &candidate.session_id,
706 vec!["cat".into(), format!("{root}/ownership.json")],
707 "read worker ownership marker",
708 ) {
709 Ok(command) => command,
710 Err(error) => {
711 tracing::debug!(
712 session_id = %candidate.session_id,
713 %error,
714 "could not construct a recovery ownership command"
715 );
716 return None;
717 }
718 };
719 let output = match executor.execute(&command) {
720 Ok(output) => output,
721 Err(error) => {
722 tracing::debug!(
723 session_id = %candidate.session_id,
724 %error,
725 "could not read a recovery worker ownership marker"
726 );
727 return None;
728 }
729 };
730 if output.status != 0 {
731 tracing::debug!(
732 session_id = %candidate.session_id,
733 status = output.status,
734 "recovery worker ownership probe returned a failure"
735 );
736 return None;
737 }
738 let marker: WorkerOwnership = match serde_json::from_slice(&output.stdout) {
739 Ok(marker) => marker,
740 Err(error) => {
741 tracing::debug!(
742 session_id = %candidate.session_id,
743 %error,
744 "recovery worker ownership marker was not valid JSON"
745 );
746 return None;
747 }
748 };
749 if !(1..=WorkerOwnership::VERSION).contains(&marker.version)
750 || marker.session_id != candidate.session_id
751 || marker.target_template_id != candidate.target_template_id
752 {
753 tracing::debug!(
754 session_id = %candidate.session_id,
755 marker_session_id = %marker.session_id,
756 marker_target_template_id = %marker.target_template_id,
757 "recovery worker ownership marker did not match the candidate"
758 );
759 return None;
760 }
761 Some(marker)
762}
763
764fn recovery_backend_locator(
765 template: &TargetTemplate,
766 locator: &TargetLocator,
767 session_id: &str,
768) -> Result<hel_targets::TargetLocator> {
769 Ok(match (template, locator) {
770 (TargetTemplate::LocalBare, TargetLocator::LocalBare { worker_root }) => {
771 hel_targets::TargetLocator::LocalBare {
772 worker_root: worker_root.to_string_lossy().into_owned(),
773 }
774 }
775 (TargetTemplate::LocalPodman { .. }, TargetLocator::LocalPodman { container_id, .. }) => {
776 hel_targets::TargetLocator::LocalPodman {
777 container_id: container_id.clone(),
778 workspace_storage: Default::default(),
779 }
780 }
781 (TargetTemplate::LocalDocker { .. }, TargetLocator::LocalDocker { container_id }) => {
782 hel_targets::TargetLocator::LocalDocker {
783 container_id: container_id.clone(),
784 }
785 }
786 (TargetTemplate::AppleContainer { .. }, TargetLocator::AppleContainer { container_id }) => {
787 hel_targets::TargetLocator::AppleContainer {
788 container_id: container_id.clone(),
789 }
790 }
791 (TargetTemplate::SshPodman { ssh, .. }, TargetLocator::SshPodman { container_id, .. }) => {
792 hel_targets::TargetLocator::SshPodman {
793 ssh: backend_ssh(ssh),
794 container_id: container_id.clone(),
795 workspace_storage: Default::default(),
796 }
797 }
798 (TargetTemplate::SshBare { ssh, .. }, TargetLocator::SshBare { workspace, .. }) => {
799 hel_targets::TargetLocator::SshBare {
800 ssh: backend_ssh(ssh),
801 workspace: workspace.to_string_lossy().into_owned(),
802 }
803 }
804 (
805 TargetTemplate::AwsEc2 {
806 aws_profile,
807 region,
808 ssh_user,
809 identity_file,
810 ssh_args,
811 ..
812 },
813 TargetLocator::AwsEc2 {
814 instance_id,
815 address,
816 },
817 ) => hel_targets::TargetLocator::AwsEc2 {
818 profile: aws_profile.clone().unwrap_or_else(|| "default".into()),
819 region: region.clone(),
820 instance_id: instance_id.clone(),
821 ssh: SshTarget {
822 destination: format!(
823 "{ssh_user}@{}",
824 address.as_deref().unwrap_or("unavailable.invalid")
825 ),
826 ssh_args: ssh_args_with_identity(ssh_args, identity_file.as_deref()),
827 },
828 workspace: format!(".local/share/hel/workspaces/{session_id}"),
829 },
830 _ => bail!("recovery target locator does not match target template"),
831 })
832}
833
834#[cfg(test)]
835mod tests {
836 use std::collections::BTreeMap;
837
838 use hel::hel_config::{
839 AwsAddressSource, ContainerTemplate as ConfigContainer, HarnessKind, HelConfig,
840 TargetTemplate,
841 };
842 use hel::hel_state::{HelState, TargetLocator};
843 use hel::hel_targets::ProcessExecutor;
844
845 use super::*;
846
847 const FAILED_ADOPTION_CHILD: &str = "MJ_TEST_FAILED_ADOPTION_CHILD";
848
849 #[tokio::test]
850 async fn a_failed_adoption_records_the_failure_and_stays_retryable() {
851 if std::env::var_os(FAILED_ADOPTION_CHILD).is_none() {
854 let directory = tempfile::tempdir().unwrap();
855 let output = std::process::Command::new(std::env::current_exe().unwrap())
856 .args([
857 "--exact",
858 "hel_controller::recovery_scan::tests::\
859 a_failed_adoption_records_the_failure_and_stays_retryable",
860 "--nocapture",
861 ])
862 .env(FAILED_ADOPTION_CHILD, "1")
863 .env("MJ_DATA_DIR", directory.path())
864 .output()
865 .unwrap();
866 assert!(
867 output.status.success(),
868 "isolated adoption retry test failed\nstdout:\n{}\nstderr:\n{}",
869 String::from_utf8_lossy(&output.stdout),
870 String::from_utf8_lossy(&output.stderr)
871 );
872 return;
873 }
874 let _writer = hel::hel_database::install_isolated_test_writer();
876
877 let session_id = "0123456789abcdef0123456789abcdef";
878 let workers = tempfile::tempdir().unwrap();
879 let record = adopted_session_record(
882 session_id,
883 "local-bare",
884 "codex".into(),
885 HarnessKind::Codex,
886 "project".into(),
887 hel::hel_workspace::DEFAULT_WORKSPACE_ID.to_owned(),
888 TargetLocator::LocalBare {
889 worker_root: workers.path().join(session_id),
890 },
891 );
892 assert!(
893 adoption_unfinished(&record, "local-bare"),
894 "the record adoption commits must be the record adoption can retry"
895 );
896 hel::hel_database::save_session(&record).unwrap();
897 let mut config = HelConfig::default();
898 config
899 .targets
900 .insert("local-bare".into(), TargetTemplate::LocalBare);
901 let mut state = HelState::default();
902 state.sessions.insert(session_id.to_owned(), record);
903 let mut controller = Controller { config, state };
904
905 let failure = controller
906 .adopt_orphan_worker(session_id, "local-bare", None, None, &ProcessExecutor)
907 .await
908 .expect_err("a worker root without a worker cannot complete the handshake");
909 assert!(
910 format!("{failure:#}").contains("orphan relay"),
911 "unexpected failure: {failure:#}"
912 );
913 let recorded = controller.state.sessions[session_id]
914 .last_error
915 .clone()
916 .expect("the failed handshake was recorded on the session");
917 assert!(
918 recorded.contains("orphan adoption failed"),
919 "unexpected recorded failure: {recorded}"
920 );
921 assert_eq!(
922 controller.state.sessions[session_id].state,
923 SessionState::Disconnected
924 );
925 let stored = hel::hel_database::load_state().unwrap();
926 assert_eq!(
927 stored.sessions[session_id].last_error.as_deref(),
928 Some(recorded.as_str()),
929 "the adoption failure was not persisted"
930 );
931
932 let retry = controller
933 .adopt_orphan_worker(session_id, "local-bare", None, None, &ProcessExecutor)
934 .await
935 .expect_err("the worker is still unreachable");
936 let retry = format!("{retry:#}");
937 assert!(
938 retry.contains("orphan relay"),
939 "adoption did not retry the handshake: {retry}"
940 );
941 assert!(
942 !retry.contains("already tracked"),
943 "a session adoption never finished blocked its own retry: {retry}"
944 );
945 }
946
947 #[test]
948 fn a_session_that_completed_its_handshake_is_not_adoptable_again() {
949 let mut record = adopted_session_record(
950 "0123456789abcdef0123456789abcdef",
951 "local-bare",
952 "codex".into(),
953 HarnessKind::Codex,
954 "project".into(),
955 hel::hel_workspace::DEFAULT_WORKSPACE_ID.to_owned(),
956 TargetLocator::LocalBare {
957 worker_root: std::path::PathBuf::from("/workers/0123456789abcdef0123456789abcdef"),
958 },
959 );
960 record.native_session_id = Some("native-session".into());
961 assert!(!adoption_unfinished(&record, "local-bare"));
962
963 record.native_session_id = None;
964 assert!(
965 !adoption_unfinished(&record, "other-target"),
966 "a record adopted onto another target is not this target's retry"
967 );
968 }
969
970 #[test]
971 fn recovery_container_scan_requires_both_managed_and_session_labels() {
972 let template = TargetTemplate::LocalPodman {
973 container: ConfigContainer {
974 image: "ignored".into(),
975 pull_policy: Default::default(),
976 platform: None,
977 cpus: None,
978 memory: None,
979 environment: BTreeMap::new(),
980 workspace_storage: Default::default(),
981 },
982 };
983 let json = serde_json::json!([
984 {"Labels": {"dev.mj.managed": "true", "dev.mj.session": "0123456789abcdef0123456789abcdef"}},
985 {"Labels": {"dev.mj.managed": "false", "dev.mj.session": "not-owned"}},
986 {"configuration": {"labels": "dev.mj.managed=true,dev.mj.session=abcdef0123456789abcdef0123456789"}}
987 ]);
988 let candidates = candidates_from_container_json(
989 "local",
990 &template,
991 serde_json::to_string(&json).unwrap().as_bytes(),
992 )
993 .unwrap();
994 assert_eq!(candidates.len(), 2);
995 assert_eq!(candidates[0].session_id, "0123456789abcdef0123456789abcdef");
996 }
997
998 #[test]
999 fn recovery_docker_scan_accepts_json_lines_and_builds_a_docker_locator() {
1000 let template = TargetTemplate::LocalDocker {
1001 container: ConfigContainer {
1002 image: "ignored".into(),
1003 pull_policy: Default::default(),
1004 platform: None,
1005 cpus: None,
1006 memory: None,
1007 environment: BTreeMap::new(),
1008 workspace_storage: Default::default(),
1009 },
1010 };
1011 let session = "0123456789abcdef0123456789abcdef";
1012 let output = format!(
1013 "{{\"Labels\":\"dev.mj.managed=true,dev.mj.session={session}\"}}\n{{\"Labels\":\"dev.mj.managed=false,dev.mj.session=ignored\"}}\n"
1014 );
1015
1016 let candidates =
1017 candidates_from_container_json("docker", &template, output.as_bytes()).unwrap();
1018
1019 assert_eq!(candidates.len(), 1);
1020 assert_eq!(candidates[0].session_id, session);
1021 assert!(matches!(
1022 &candidates[0].locator,
1023 TargetLocator::LocalDocker { container_id }
1024 if container_id == &hel_targets::resource_name(session).unwrap()
1025 ));
1026 }
1027
1028 #[test]
1029 fn recovery_aws_scan_uses_exact_tagged_instance_and_address() {
1030 let json = serde_json::json!({"Reservations": [{"Instances": [{
1031 "InstanceId": "i-exact",
1032 "PrivateIpAddress": "10.0.0.7",
1033 "Tags": [
1034 {"Key": "dev.mj.managed", "Value": "true"},
1035 {"Key": "dev.mj.session", "Value": "0123456789abcdef0123456789abcdef"}
1036 ]
1037 }]}]});
1038 let candidates = candidates_from_aws_json(
1039 "aws",
1040 AwsAddressSource::PrivateIp,
1041 serde_json::to_string(&json).unwrap().as_bytes(),
1042 )
1043 .unwrap();
1044 assert!(matches!(
1045 &candidates[0].locator,
1046 TargetLocator::AwsEc2 { instance_id, address }
1047 if instance_id == "i-exact" && address.as_deref() == Some("10.0.0.7")
1048 ));
1049 }
1050}