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