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::{
10 PodmanWorkspaceLocator, SessionRecord, SessionState, TargetLocator, normalize_session_title,
11};
12
13use crate::targets::{self, CommandExecutor, CommandOutput, CommandSpec, SshTarget};
14use mj_core::worker_launch::WorkerOwnership;
15
16use super::backend::{ContainerOverrides, backend_locator, backend_target};
17use super::readiness::wait_for_native_session;
18use super::{Controller, now};
19
20pub use mj_core::state::{RecoveryCandidate, RecoveryScan};
21
22impl Controller {
23 pub fn scan_orphan_workers(
31 &self,
32 executor: &impl CommandExecutor,
33 all_instances: bool,
34 ) -> RecoveryScan {
35 let mut scan = RecoveryScan {
36 instance_id: mj_core::config::instance_identity(),
37 ..RecoveryScan::default()
38 };
39 for (target_id, template) in &self.config.targets {
40 match scan_target_workers(target_id, template, executor) {
41 Ok(candidates) => {
42 scan.candidates
43 .extend(candidates.into_iter().filter(|candidate| {
44 !self.state.sessions.contains_key(&candidate.session_id)
45 }))
46 }
47 Err(error) => scan.warnings.push(format!("target {target_id}: {error:#}")),
48 }
49 }
50 scan.candidates.sort_by(|left, right| {
51 (&left.session_id, &left.target_template_id)
52 .cmp(&(&right.session_id, &right.target_template_id))
53 });
54 scan.candidates.dedup_by(|left, right| {
55 left.session_id == right.session_id
56 && left.target_template_id == right.target_template_id
57 });
58 if !all_instances {
59 restrict_to_instance(&mut scan);
60 }
61 scan
62 }
63
64 pub async fn adopt_orphan_worker(
65 &mut self,
66 session_id: &str,
67 target_id: &str,
68 profile_override: Option<&str>,
69 bundle_override: Option<&str>,
70 all_instances: bool,
71 executor: &impl CommandExecutor,
72 ) -> Result<()> {
73 let (record, newly_adopted) = match self.state.sessions.get(session_id).cloned() {
74 Some(existing) if adoption_unfinished(&existing, target_id) => {
78 for (flag, requested, adopted) in [
79 ("profile", profile_override, existing.last_profile.as_str()),
80 ("bundle", bundle_override, existing.bundle_id.as_str()),
81 ] {
82 if let Some(requested) = requested
83 && requested != adopted
84 {
85 bail!(
86 "session {session_id} was already adopted with {flag} {adopted:?}; retry without --{flag}"
87 );
88 }
89 }
90 (existing, false)
91 }
92 Some(_) => bail!("session {session_id} is already tracked"),
93 None => {
94 let scan = self.scan_orphan_workers(executor, true);
95 let candidate = scan
96 .candidates
97 .into_iter()
98 .find(|candidate| {
99 candidate.session_id == session_id
100 && candidate.target_template_id == target_id
101 })
102 .with_context(|| {
103 format!("no managed orphan {session_id} was found on target {target_id:?}")
104 })?;
105 require_instance_access(&candidate, &scan.instance_id, all_instances)?;
106 let profile_id = profile_override
107 .map(str::to_owned)
108 .or_else(|| {
109 candidate
110 .ownership
111 .as_ref()
112 .map(|marker| marker.profile_id.clone())
113 })
114 .context("orphan has no ownership marker; pass --profile")?;
115 let bundle_id = bundle_override
116 .map(str::to_owned)
117 .or_else(|| {
118 candidate
119 .ownership
120 .as_ref()
121 .map(|marker| marker.bundle_id.clone())
122 })
123 .context("orphan has no ownership marker; pass --bundle")?;
124 let profile = self
125 .config
126 .profiles
127 .get(&profile_id)
128 .with_context(|| format!("unknown profile {profile_id:?}"))?;
129 self.config
130 .bundles
131 .get(&bundle_id)
132 .with_context(|| format!("unknown bundle {bundle_id:?}"))?;
133 let workspace_id = resolve_recovery_workspace_id(
134 candidate
135 .ownership
136 .as_ref()
137 .map(|ownership| ownership.workspace_id.as_str())
138 .unwrap_or(mj_core::workspace::DEFAULT_WORKSPACE_ID),
139 )?;
140 let container_workspace = self
141 .config
142 .targets
143 .get(target_id)
144 .and_then(|template| {
145 recovery_backend_locator(template, &candidate.locator, session_id).ok()
146 })
147 .and_then(|backend| {
148 adopted_container_workspace(&backend, session_id, executor)
149 });
150 let mut record = adopted_session_record(
151 session_id,
152 target_id,
153 profile_id,
154 profile.kind,
155 bundle_id,
156 workspace_id,
157 candidate.locator,
158 );
159 record.container_workspace = container_workspace;
160 (record, true)
161 }
162 };
163 let locator = record
164 .target
165 .as_ref()
166 .context("adopted session has no target locator")?;
167 let backend = backend_locator(locator, &record, &self.config)?;
168 let spec = targets::reconnect_plan(&backend, session_id)?
169 .commands
170 .into_iter()
171 .next()
172 .context("reconnect plan is empty")?;
173 if newly_adopted {
174 crate::database::save_session(&record)?;
179 self.state.sessions.insert(session_id.to_owned(), record);
180 }
181 match self.complete_adoption(session_id, &spec, executor).await {
182 Ok(()) => Ok(()),
183 Err(error) => Err(self.record_adoption_failure(session_id, error)),
187 }
188 }
189
190 async fn complete_adoption(
192 &mut self,
193 session_id: &str,
194 spec: &CommandSpec,
195 executor: &impl CommandExecutor,
196 ) -> Result<()> {
197 let mut relay = StandaloneSession::connect_command(spec, session_id)
198 .await
199 .context("orphan relay did not complete the v1 handshake")?;
200 let native_session_id = wait_for_native_session(&mut relay, executor).await?;
201 self.mark_worker_connected(session_id, Some(native_session_id))?;
202 if let Some(title) = relay
203 .snapshot()
204 .materialized
205 .session_title
206 .as_deref()
207 .and_then(normalize_session_title)
208 {
209 crate::database::set_session_acp_title(session_id, Some(&title))?;
210 self.state
211 .sessions
212 .get_mut(session_id)
213 .expect("adopted session disappeared while saving its ACP title")
214 .acp_session_title = Some(title);
215 }
216 Ok(())
217 }
218
219 fn record_adoption_failure(&mut self, session_id: &str, error: anyhow::Error) -> anyhow::Error {
224 let Some(record) = self.state.sessions.get_mut(session_id) else {
225 return error;
226 };
227 record.updated_at = now();
228 record.last_error = Some(format!("orphan adoption failed: {error:#}"));
229 match self.persist_session_state(session_id) {
230 Ok(()) => error,
231 Err(persist_error) => error.context(format!(
232 "recorded the adoption failure in memory, but failed to persist it: {persist_error:#}"
233 )),
234 }
235 }
236
237 pub fn destroy_orphan_worker(
238 &self,
239 session_id: &str,
240 target_id: &str,
241 confirmation: &str,
242 all_instances: bool,
243 executor: &impl CommandExecutor,
244 ) -> Result<()> {
245 if confirmation != session_id {
246 bail!("refusing destructive recovery: --confirm must exactly match the session ID");
247 }
248 let scan = self.scan_orphan_workers(executor, true);
249 let candidate = scan
250 .candidates
251 .into_iter()
252 .find(|candidate| {
253 candidate.session_id == session_id && candidate.target_template_id == target_id
254 })
255 .with_context(|| {
256 format!("no managed orphan {session_id} was found on target {target_id:?}")
257 })?;
258 require_instance_access(&candidate, &scan.instance_id, all_instances)?;
259 let template = self.config.targets.get(target_id).unwrap();
260 let backend = recovery_backend_locator(template, &candidate.locator, session_id)?;
261 targets::close_plan(&backend, session_id)?
262 .execute(executor)
263 .map(|_| ())
264 }
265}
266
267fn restrict_to_instance(scan: &mut RecoveryScan) {
270 let before = scan.candidates.len();
271 scan.candidates
272 .retain(|candidate| candidate.instance_id.as_deref() == Some(scan.instance_id.as_str()));
273 scan.hidden_other_instances = before - scan.candidates.len();
274}
275
276fn require_instance_access(
279 candidate: &RecoveryCandidate,
280 scan_instance: &str,
281 all_instances: bool,
282) -> Result<()> {
283 if all_instances {
284 return Ok(());
285 }
286 match candidate.instance_id.as_deref() {
287 Some(instance) if instance == scan_instance => Ok(()),
288 Some(other) => bail!(
289 "worker {} belongs to instance {other:?}, not this instance {scan_instance:?}; pass --all-instances to act on it",
290 candidate.session_id
291 ),
292 None => bail!(
293 "worker {} has no instance stamp (created by an older build); pass --all-instances to act on it",
294 candidate.session_id
295 ),
296 }
297}
298
299fn resolve_recovery_workspace_id(marked_workspace_id: &str) -> Result<String> {
303 if marked_workspace_id == mj_core::workspace::DEFAULT_WORKSPACE_ID {
304 return Ok(marked_workspace_id.to_owned());
305 }
306 if crate::database::list_workspaces()?
307 .into_iter()
308 .any(|workspace| workspace.id == marked_workspace_id)
309 {
310 return Ok(marked_workspace_id.to_owned());
311 }
312 Ok(crate::database::create_or_get_workspace("Recovered")?.id)
313}
314
315fn adopted_container_workspace(
320 backend: &targets::TargetLocator,
321 session_id: &str,
322 executor: &impl CommandExecutor,
323) -> Option<PathBuf> {
324 if !matches!(
325 backend,
326 targets::TargetLocator::LocalPodman { .. }
327 | targets::TargetLocator::LocalDocker { .. }
328 | targets::TargetLocator::AppleContainer { .. }
329 | targets::TargetLocator::SshPodman { .. }
330 | targets::TargetLocator::SshDocker { .. }
331 ) {
332 return None;
333 }
334 let workspace = targets::new_container_workspace(session_id).ok()?;
335 let command = targets::command_on_locator(
336 backend,
337 session_id,
338 vec![
339 "test".to_owned(),
340 "-d".to_owned(),
341 workspace.to_string_lossy().into_owned(),
342 ],
343 "probe the adopted session workspace",
344 )
345 .ok()?;
346 match executor.execute(&command) {
347 Ok(output) if output.status == 0 => Some(workspace),
348 Ok(_) => None,
349 Err(error) => {
350 tracing::debug!(
351 session_id,
352 %error,
353 "could not probe the adopted session workspace; assuming the shared one"
354 );
355 None
356 }
357 }
358}
359
360fn adopted_session_record(
362 session_id: &str,
363 target_id: &str,
364 profile_id: String,
365 harness_kind: mj_core::config::HarnessKind,
366 bundle_id: String,
367 workspace_id: String,
368 locator: TargetLocator,
369) -> SessionRecord {
370 let now = now();
371 SessionRecord {
372 build_cache: None,
373 mjolnir_subagents: None,
374 container_workspace: None,
376 create_managed_worktree: None,
377 workspace_id,
378 archived: false,
379 container_cpus: None,
380 container_memory: None,
381 id: session_id.to_owned(),
382 title: format!("Recovered {}", &session_id[..session_id.len().min(8)]),
383 harness_kind,
384 last_profile: profile_id,
385 bundle_id,
386 project_directory: None,
387 managed_worktree: None,
388 target_template_id: target_id.to_owned(),
389 resource_allocation: None,
390 additional_mounts: Vec::new(),
391 state: SessionState::Disconnected,
392 target: Some(locator),
393 native_session_id: None,
394 acp_session_title: None,
395 session_title_override: None,
396 created_at: now.clone(),
397 updated_at: now,
398 viewed_through_event_ordinal: 0,
399 draft_input: String::new(),
400 last_error: None,
401 last_checkpoint_error: None,
402 checkpoint: None,
403 }
404}
405
406fn adoption_unfinished(record: &SessionRecord, target_id: &str) -> bool {
411 record.state == SessionState::Disconnected
412 && record.native_session_id.is_none()
413 && record.target_template_id == target_id
414 && record.target.is_some()
415}
416
417fn scan_target_workers(
418 target_id: &str,
419 template: &TargetTemplate,
420 executor: &impl CommandExecutor,
421) -> Result<Vec<RecoveryCandidate>> {
422 let mut candidates = match template {
423 TargetTemplate::LocalBare => Vec::new(),
426 TargetTemplate::LocalPodman { .. } => scan_container_engine(
427 target_id,
428 template,
429 "podman",
430 vec![
431 "ps".into(),
432 "--all".into(),
433 "--filter".into(),
434 format!("label={}=true", targets::MANAGED_LABEL),
435 "--format".into(),
436 "json".into(),
437 ],
438 executor,
439 )?,
440 TargetTemplate::LocalDocker { .. } => scan_container_engine(
441 target_id,
442 template,
443 "docker",
444 vec![
445 "ps".into(),
446 "--all".into(),
447 "--filter".into(),
448 format!("label={}=true", targets::MANAGED_LABEL),
449 "--format".into(),
450 "json".into(),
451 ],
452 executor,
453 )?,
454 TargetTemplate::AppleContainer { .. } => scan_container_engine(
455 target_id,
456 template,
457 "container",
458 vec![
459 "list".into(),
460 "--all".into(),
461 "--format".into(),
462 "json".into(),
463 ],
464 executor,
465 )?,
466 TargetTemplate::SshPodman { ssh, .. } => {
467 let remote = targets::join_remote_command(&[
468 "podman".into(),
469 "ps".into(),
470 "--all".into(),
471 "--filter".into(),
472 format!("label={}=true", targets::MANAGED_LABEL),
473 "--format".into(),
474 "json".into(),
475 ]);
476 let output = execute_scan(
477 executor,
478 ssh_spec(ssh, [remote]),
479 "scan remote Podman workers",
480 )?;
481 candidates_from_container_json(target_id, template, &output.stdout)?
482 }
483 TargetTemplate::SshDocker { ssh, .. } => {
484 let remote = targets::join_remote_command(&[
485 "docker".into(),
486 "ps".into(),
487 "--all".into(),
488 "--filter".into(),
489 format!("label={}=true", targets::MANAGED_LABEL),
490 "--format".into(),
491 "json".into(),
492 ]);
493 let output = execute_scan(
494 executor,
495 ssh_spec(ssh, [remote]),
496 "scan remote Docker workers",
497 )?;
498 candidates_from_container_json(target_id, template, &output.stdout)?
499 }
500 TargetTemplate::AwsEc2 {
501 aws_profile,
502 region,
503 address_source,
504 ..
505 } => {
506 let profile = aws_profile.clone().unwrap_or_else(|| "default".into());
507 let output = execute_scan(
508 executor,
509 CommandSpec::new(
510 "aws",
511 [
512 "--profile".into(),
513 profile,
514 "--region".into(),
515 region.clone(),
516 "ec2".into(),
517 "describe-instances".into(),
518 "--filters".into(),
519 format!("Name=tag:{},Values=true", targets::MANAGED_TAG),
520 "Name=instance-state-name,Values=pending,running,stopping,stopped".into(),
521 "--output".into(),
522 "json".into(),
523 ],
524 )
525 .purpose("scan managed EC2 workers"),
526 "scan managed EC2 workers",
527 )?;
528 candidates_from_aws_json(target_id, address_source.clone(), &output.stdout)?
529 }
530 TargetTemplate::SshBare { ssh, .. } => {
531 let output = execute_scan(
532 executor,
533 ssh_spec(
534 ssh,
535 [targets::join_remote_command(&[
536 "find".into(),
537 ".local/share/hel/workers".into(),
538 "-mindepth".into(),
539 "2".into(),
540 "-maxdepth".into(),
541 "2".into(),
542 "-name".into(),
543 "ownership.json".into(),
544 "-print".into(),
545 ])],
546 ),
547 "scan bare SSH worker markers",
548 )?;
549 output
550 .stdout
551 .split(|byte| *byte == b'\n')
552 .filter_map(|line| {
553 let path = match std::str::from_utf8(line) {
554 Ok(path) => path.trim(),
555 Err(error) => {
556 tracing::debug!(%error, "recovery scan skipped a non-UTF-8 worker marker path");
557 return None;
558 }
559 };
560 let Some(session_id) = Path::new(path)
561 .parent()
562 .and_then(|parent| parent.file_name())
563 .and_then(|name| name.to_str())
564 else {
565 tracing::debug!(path, "recovery scan skipped a malformed worker marker path");
566 return None;
567 };
568 if let Err(error) = targets::resource_name(session_id) {
569 tracing::debug!(session_id, %error, "recovery scan skipped an invalid session id");
570 return None;
571 }
572 let backend = match backend_target(template, None, ContainerOverrides::default()) {
573 Ok(backend) => backend,
574 Err(error) => {
575 tracing::debug!(session_id, %error, "recovery scan could not construct the target backend");
576 return None;
577 }
578 };
579 let workspace = match targets::workspace_for(&backend, session_id) {
580 Ok(workspace) => workspace,
581 Err(error) => {
582 tracing::debug!(session_id, %error, "recovery scan could not derive the target workspace");
583 return None;
584 }
585 };
586 Some(RecoveryCandidate {
587 session_id: session_id.to_owned(),
588 target_template_id: target_id.to_owned(),
589 locator: TargetLocator::SshBare {
590 host: ssh.host.clone(),
591 workspace: PathBuf::from(workspace),
592 worker_id: None,
596 },
597 ownership: None,
598 instance_id: None,
599 })
600 })
601 .collect()
602 }
603 };
604 for candidate in &mut candidates {
605 candidate.ownership = read_recovery_ownership(template, candidate, executor);
606 if candidate.instance_id.is_none() {
609 candidate.instance_id = candidate
610 .ownership
611 .as_ref()
612 .and_then(|marker| marker.instance_id.clone());
613 }
614 }
615 Ok(candidates)
616}
617
618fn scan_container_engine(
619 target_id: &str,
620 template: &TargetTemplate,
621 engine: &str,
622 args: Vec<String>,
623 executor: &impl CommandExecutor,
624) -> Result<Vec<RecoveryCandidate>> {
625 let output = execute_scan(
626 executor,
627 CommandSpec::new(engine, args).purpose("scan managed container workers"),
628 "scan managed container workers",
629 )?;
630 candidates_from_container_json(target_id, template, &output.stdout)
631}
632
633fn recovery_workspace_storage(
636 template: &TargetTemplate,
637 session_id: &str,
638) -> Result<PodmanWorkspaceLocator> {
639 let backend = backend_target(template, None, ContainerOverrides::default())?;
640 let container = match &backend {
641 targets::TargetTemplate::LocalPodman(container) => container,
642 targets::TargetTemplate::SshPodman { container, .. } => container,
643 _ => bail!("target template is not a Podman target"),
644 };
645 Ok(PodmanWorkspaceLocator::from(
646 targets::podman_workspace_locator(container, session_id)?,
647 ))
648}
649
650fn candidates_from_container_json(
651 target_id: &str,
652 template: &TargetTemplate,
653 stdout: &[u8],
654) -> Result<Vec<RecoveryCandidate>> {
655 let sessions = managed_sessions_from_container_json(stdout)?;
656 Ok(sessions
657 .into_iter()
658 .filter_map(|(session_id, instance_id)| {
659 let generated = match targets::resource_name(&session_id) {
660 Ok(generated) => generated,
661 Err(error) => {
662 tracing::debug!(%session_id, %error, "recovery scan skipped an invalid managed session id");
663 return None;
664 }
665 };
666 let workspace_storage = match template {
671 TargetTemplate::LocalPodman { .. } | TargetTemplate::SshPodman { .. } => {
672 match recovery_workspace_storage(template, &session_id) {
673 Ok(storage) => storage,
674 Err(error) => {
675 tracing::debug!(%session_id, %error, "recovery scan could not derive the Podman workspace storage");
676 return None;
677 }
678 }
679 }
680 _ => Default::default(),
681 };
682 let locator = match template {
683 TargetTemplate::LocalPodman { .. } => TargetLocator::LocalPodman {
684 borrowed_from: None,
685 container_id: generated,
686 workspace_storage,
687 },
688 TargetTemplate::LocalDocker { .. } => TargetLocator::LocalDocker {
689 borrowed_from: None,
690 container_id: generated,
691 },
692 TargetTemplate::AppleContainer { .. } => TargetLocator::AppleContainer {
693 borrowed_from: None,
694 container_id: generated,
695 },
696 TargetTemplate::SshPodman { ssh, .. } => TargetLocator::SshPodman {
697 borrowed_from: None,
698 host: ssh.host.clone(),
699 container_id: generated,
700 workspace_storage,
701 },
702 TargetTemplate::SshDocker { ssh, .. } => TargetLocator::SshDocker {
703 borrowed_from: None,
704 host: ssh.host.clone(),
705 container_id: generated,
706 },
707 _ => return None,
708 };
709 Some(RecoveryCandidate {
710 session_id,
711 target_template_id: target_id.to_owned(),
712 locator,
713 ownership: None,
714 instance_id,
715 })
716 })
717 .collect())
718}
719
720pub(super) fn managed_sessions_from_container_json(
723 stdout: &[u8],
724) -> Result<Vec<(String, Option<String>)>> {
725 let values = serde_json::Deserializer::from_slice(stdout)
726 .into_iter::<serde_json::Value>()
727 .collect::<std::result::Result<Vec<_>, _>>()
728 .context("parse container list JSON")?;
729 let mut sessions = Vec::new();
730 for value in &values {
731 collect_managed_sessions(value, &mut sessions);
732 }
733 sessions.sort();
734 sessions.dedup();
735 Ok(sessions)
736}
737
738pub(super) fn collect_managed_sessions(
739 value: &serde_json::Value,
740 sessions: &mut Vec<(String, Option<String>)>,
741) {
742 match value {
743 serde_json::Value::Array(values) => {
744 for value in values {
745 collect_managed_sessions(value, sessions);
746 }
747 }
748 serde_json::Value::Object(object) => {
749 for label_key in ["Labels", "labels"] {
750 if let Some(labels) = object.get(label_key) {
751 let managed = label_value(labels, targets::MANAGED_LABEL)
752 .is_some_and(|value| value == "true");
753 if managed && let Some(session) = label_value(labels, targets::SESSION_LABEL) {
754 sessions.push((session, label_value(labels, targets::INSTANCE_LABEL)));
755 }
756 }
757 }
758 for value in object.values() {
759 collect_managed_sessions(value, sessions);
760 }
761 }
762 _ => {}
763 }
764}
765
766fn label_value(labels: &serde_json::Value, key: &str) -> Option<String> {
767 match labels {
768 serde_json::Value::Object(object) => object.get(key)?.as_str().map(str::to_owned),
769 serde_json::Value::String(text) => text
770 .split(',')
771 .find_map(|label| {
772 label
773 .trim()
774 .split_once('=')
775 .filter(|(name, _)| *name == key)
776 })
777 .map(|(_, value)| value.to_owned()),
778 _ => None,
779 }
780}
781
782fn candidates_from_aws_json(
783 target_id: &str,
784 address_source: AwsAddressSource,
785 stdout: &[u8],
786) -> Result<Vec<RecoveryCandidate>> {
787 let value: serde_json::Value =
788 serde_json::from_slice(stdout).context("parse AWS instance JSON")?;
789 let mut result = Vec::new();
790 let reservations = value
791 .get("Reservations")
792 .and_then(serde_json::Value::as_array)
793 .cloned()
794 .unwrap_or_default();
795 for instance in reservations.iter().flat_map(|reservation| {
796 reservation
797 .get("Instances")
798 .and_then(serde_json::Value::as_array)
799 .into_iter()
800 .flatten()
801 }) {
802 let tags = instance
803 .get("Tags")
804 .and_then(serde_json::Value::as_array)
805 .cloned()
806 .unwrap_or_default();
807 let tag = |key: &str| {
808 tags.iter()
809 .find(|tag| tag.get("Key").and_then(serde_json::Value::as_str) == Some(key))
810 .and_then(|tag| tag.get("Value"))
811 .and_then(serde_json::Value::as_str)
812 };
813 if tag(targets::MANAGED_TAG) != Some("true") {
814 continue;
815 }
816 let Some(session_id) = tag(targets::SESSION_TAG).map(str::to_owned) else {
817 continue;
818 };
819 targets::resource_name(&session_id)?;
820 let instance_id = instance
821 .get("InstanceId")
822 .and_then(serde_json::Value::as_str)
823 .context("managed EC2 instance omitted InstanceId")?
824 .to_owned();
825 let field = match address_source {
826 AwsAddressSource::PublicDns => "PublicDnsName",
827 AwsAddressSource::PublicIp => "PublicIpAddress",
828 AwsAddressSource::PrivateDns => "PrivateDnsName",
829 AwsAddressSource::PrivateIp => "PrivateIpAddress",
830 };
831 let address = instance
832 .get(field)
833 .and_then(serde_json::Value::as_str)
834 .filter(|value| !value.is_empty())
835 .map(str::to_owned);
836 let created_by = tag(targets::INSTANCE_TAG).map(str::to_owned);
837 result.push(RecoveryCandidate {
838 session_id,
839 target_template_id: target_id.to_owned(),
840 locator: TargetLocator::AwsEc2 {
841 instance_id,
842 address,
843 },
844 ownership: None,
845 instance_id: created_by,
846 });
847 }
848 Ok(result)
849}
850
851fn execute_scan(
852 executor: &impl CommandExecutor,
853 command: CommandSpec,
854 operation: &str,
855) -> Result<CommandOutput> {
856 let output = executor.execute(&command)?;
857 if output.status != 0 {
858 bail!(
859 "{operation} failed with status {}: {}",
860 output.status,
861 String::from_utf8_lossy(&output.stderr).trim()
862 );
863 }
864 Ok(output)
865}
866
867fn ssh_spec(ssh: &SshConnection, remote: impl IntoIterator<Item = String>) -> CommandSpec {
868 let backend = SshTarget::from(ssh);
869 let mut args = backend.ssh_args;
870 mj_core::targets::push_connection_sharing_args(&mut args);
871 args.push(backend.destination.clone());
872 args.extend(remote);
873 CommandSpec::new("ssh", args).ssh_destination(backend.destination)
874}
875
876fn read_recovery_ownership(
877 template: &TargetTemplate,
878 candidate: &RecoveryCandidate,
879 executor: &impl CommandExecutor,
880) -> Option<WorkerOwnership> {
881 let backend =
882 match recovery_backend_locator(template, &candidate.locator, &candidate.session_id) {
883 Ok(backend) => backend,
884 Err(error) => {
885 tracing::debug!(
886 session_id = %candidate.session_id,
887 %error,
888 "could not construct a recovery ownership probe"
889 );
890 return None;
891 }
892 };
893 let root = match targets::worker_root(&backend, &candidate.session_id) {
894 Ok(root) => root,
895 Err(error) => {
896 tracing::debug!(
897 session_id = %candidate.session_id,
898 %error,
899 "could not derive a recovery worker root"
900 );
901 return None;
902 }
903 };
904 let command = match targets::command_on_locator(
905 &backend,
906 &candidate.session_id,
907 vec!["cat".into(), format!("{root}/ownership.json")],
908 "read worker ownership marker",
909 ) {
910 Ok(command) => command,
911 Err(error) => {
912 tracing::debug!(
913 session_id = %candidate.session_id,
914 %error,
915 "could not construct a recovery ownership command"
916 );
917 return None;
918 }
919 };
920 let output = match executor.execute(&command) {
921 Ok(output) => output,
922 Err(error) => {
923 tracing::debug!(
924 session_id = %candidate.session_id,
925 %error,
926 "could not read a recovery worker ownership marker"
927 );
928 return None;
929 }
930 };
931 if output.status != 0 {
932 tracing::debug!(
933 session_id = %candidate.session_id,
934 status = output.status,
935 "recovery worker ownership probe returned a failure"
936 );
937 return None;
938 }
939 let marker: WorkerOwnership = match serde_json::from_slice(&output.stdout) {
940 Ok(marker) => marker,
941 Err(error) => {
942 tracing::debug!(
943 session_id = %candidate.session_id,
944 %error,
945 "recovery worker ownership marker was not valid JSON"
946 );
947 return None;
948 }
949 };
950 if !(1..=WorkerOwnership::VERSION).contains(&marker.version)
951 || marker.session_id != candidate.session_id
952 || marker.target_template_id != candidate.target_template_id
953 {
954 tracing::debug!(
955 session_id = %candidate.session_id,
956 marker_session_id = %marker.session_id,
957 marker_target_template_id = %marker.target_template_id,
958 "recovery worker ownership marker did not match the candidate"
959 );
960 return None;
961 }
962 Some(marker)
963}
964
965fn recovery_backend_locator(
981 template: &TargetTemplate,
982 locator: &TargetLocator,
983 session_id: &str,
984) -> Result<targets::TargetLocator> {
985 Ok(match (template, locator) {
986 (TargetTemplate::LocalBare, TargetLocator::LocalBare { worker_root }) => {
987 targets::TargetLocator::LocalBare {
988 worker_root: worker_root.to_string_lossy().into_owned(),
989 }
990 }
991 (
992 TargetTemplate::LocalPodman { .. },
993 TargetLocator::LocalPodman {
994 container_id,
995 workspace_storage,
996 ..
997 },
998 ) => targets::TargetLocator::LocalPodman {
999 borrowed_from: None,
1000 container_id: container_id.clone(),
1001 workspace_storage: workspace_storage.into(),
1002 },
1003 (TargetTemplate::LocalDocker { .. }, TargetLocator::LocalDocker { container_id, .. }) => {
1004 targets::TargetLocator::LocalDocker {
1005 borrowed_from: None,
1006 container_id: container_id.clone(),
1007 }
1008 }
1009 (
1010 TargetTemplate::AppleContainer { .. },
1011 TargetLocator::AppleContainer { container_id, .. },
1012 ) => targets::TargetLocator::AppleContainer {
1013 borrowed_from: None,
1014 container_id: container_id.clone(),
1015 },
1016 (
1017 TargetTemplate::SshPodman { ssh, .. },
1018 TargetLocator::SshPodman {
1019 container_id,
1020 workspace_storage,
1021 ..
1022 },
1023 ) => targets::TargetLocator::SshPodman {
1024 borrowed_from: None,
1025 ssh: SshTarget::from(ssh),
1026 container_id: container_id.clone(),
1027 workspace_storage: workspace_storage.into(),
1028 },
1029 (
1030 TargetTemplate::SshDocker { ssh, .. },
1031 TargetLocator::SshDocker {
1032 host, container_id, ..
1033 },
1034 ) => {
1035 if host != &ssh.host {
1036 bail!("recovery SSH Docker host does not match target template")
1037 }
1038 targets::TargetLocator::SshDocker {
1039 borrowed_from: None,
1040 ssh: SshTarget::from(ssh),
1041 container_id: container_id.clone(),
1042 }
1043 }
1044 (TargetTemplate::SshBare { ssh, .. }, TargetLocator::SshBare { workspace, .. }) => {
1045 targets::TargetLocator::SshBare {
1046 ssh: SshTarget::from(ssh),
1047 workspace: workspace.to_string_lossy().into_owned(),
1048 worker_id: None,
1049 }
1050 }
1051 (
1052 TargetTemplate::AwsEc2 {
1053 aws_profile,
1054 region,
1055 ssh_user,
1056 identity_file,
1057 ssh_args,
1058 ..
1059 },
1060 TargetLocator::AwsEc2 {
1061 instance_id,
1062 address,
1063 },
1064 ) => targets::TargetLocator::AwsEc2 {
1065 profile: aws_profile.clone().unwrap_or_else(|| "default".into()),
1066 region: region.clone(),
1067 instance_id: instance_id.clone(),
1068 ssh: SshTarget {
1069 destination: format!(
1070 "{ssh_user}@{}",
1071 address.as_deref().unwrap_or("unavailable.invalid")
1072 ),
1073 ssh_args: targets::ssh_args_with_identity(ssh_args, identity_file.as_deref()),
1074 },
1075 workspace: format!(".local/share/hel/workspaces/{session_id}"),
1076 },
1077 _ => bail!("recovery target locator does not match target template"),
1078 })
1079}
1080
1081#[cfg(test)]
1082mod tests {
1083 use std::collections::BTreeMap;
1084
1085 use crate::controller::test_support::{IsolatedTest, test_name};
1086 use mj_core::config::{
1087 AwsAddressSource, Config, ContainerTemplate as ConfigContainer, HarnessKind,
1088 PodmanWorkspaceStorage, TargetTemplate,
1089 };
1090 use mj_core::state::{State, TargetLocator};
1091
1092 use crate::targets::ProcessExecutor;
1093
1094 use super::*;
1095
1096 const FAILED_ADOPTION_CHILD: &str = "MJ_TEST_FAILED_ADOPTION_CHILD";
1097
1098 #[tokio::test]
1099 async fn a_failed_adoption_records_the_failure_and_stays_retryable() {
1100 if std::env::var_os(FAILED_ADOPTION_CHILD).is_none() {
1103 let directory = tempfile::tempdir().unwrap();
1104 IsolatedTest::new(test_name(
1105 module_path!(),
1106 "a_failed_adoption_records_the_failure_and_stays_retryable",
1107 ))
1108 .env(FAILED_ADOPTION_CHILD, "1")
1109 .env("MJ_DATA_DIR", directory.path())
1110 .run();
1111 return;
1112 }
1113 let _writer = crate::database::install_isolated_test_writer();
1115
1116 let session_id = "0123456789abcdef0123456789abcdef";
1117 let workers = tempfile::tempdir().unwrap();
1118 let record = adopted_session_record(
1121 session_id,
1122 "local-bare",
1123 "codex".into(),
1124 HarnessKind::Codex,
1125 "project".into(),
1126 mj_core::workspace::DEFAULT_WORKSPACE_ID.to_owned(),
1127 TargetLocator::LocalBare {
1128 worker_root: workers.path().join(session_id),
1129 },
1130 );
1131 assert!(
1132 adoption_unfinished(&record, "local-bare"),
1133 "the record adoption commits must be the record adoption can retry"
1134 );
1135 crate::database::save_session(&record).unwrap();
1136 let mut config = Config::default();
1137 config
1138 .targets
1139 .insert("local-bare".into(), TargetTemplate::LocalBare);
1140 let mut state = State::default();
1141 state.sessions.insert(session_id.to_owned(), record);
1142 let mut controller = Controller { config, state };
1143
1144 let failure = controller
1145 .adopt_orphan_worker(
1146 session_id,
1147 "local-bare",
1148 None,
1149 None,
1150 false,
1151 &ProcessExecutor,
1152 )
1153 .await
1154 .expect_err("a worker root without a worker cannot complete the handshake");
1155 assert!(
1156 format!("{failure:#}").contains("orphan relay"),
1157 "unexpected failure: {failure:#}"
1158 );
1159 let recorded = controller.state.sessions[session_id]
1160 .last_error
1161 .clone()
1162 .expect("the failed handshake was recorded on the session");
1163 assert!(
1164 recorded.contains("orphan adoption failed"),
1165 "unexpected recorded failure: {recorded}"
1166 );
1167 assert_eq!(
1168 controller.state.sessions[session_id].state,
1169 SessionState::Disconnected
1170 );
1171 let stored = crate::database::load_state().unwrap();
1172 assert_eq!(
1173 stored.sessions[session_id].last_error.as_deref(),
1174 Some(recorded.as_str()),
1175 "the adoption failure was not persisted"
1176 );
1177
1178 let retry = controller
1179 .adopt_orphan_worker(
1180 session_id,
1181 "local-bare",
1182 None,
1183 None,
1184 false,
1185 &ProcessExecutor,
1186 )
1187 .await
1188 .expect_err("the worker is still unreachable");
1189 let retry = format!("{retry:#}");
1190 assert!(
1191 retry.contains("orphan relay"),
1192 "adoption did not retry the handshake: {retry}"
1193 );
1194 assert!(
1195 !retry.contains("already tracked"),
1196 "a session adoption never finished blocked its own retry: {retry}"
1197 );
1198 }
1199
1200 const RECOVERY_WORKSPACE_CHILD: &str = "MJ_TEST_RECOVERY_WORKSPACE_CHILD";
1201
1202 #[tokio::test]
1203 async fn orphan_workspace_ids_are_reconciled_before_adoption_persistence() {
1204 if std::env::var_os(RECOVERY_WORKSPACE_CHILD).is_none() {
1205 let directory = tempfile::tempdir().unwrap();
1206 IsolatedTest::new(test_name(
1207 module_path!(),
1208 "orphan_workspace_ids_are_reconciled_before_adoption_persistence",
1209 ))
1210 .env(RECOVERY_WORKSPACE_CHILD, "1")
1211 .env("MJ_DATA_DIR", directory.path())
1212 .run();
1213 return;
1214 }
1215
1216 let _writer = crate::database::install_isolated_test_writer();
1217 let default =
1218 resolve_recovery_workspace_id(mj_core::workspace::DEFAULT_WORKSPACE_ID).unwrap();
1219 assert_eq!(default, mj_core::workspace::DEFAULT_WORKSPACE_ID);
1220
1221 let known = crate::database::create_or_get_workspace("Known").unwrap();
1222 assert_eq!(resolve_recovery_workspace_id(&known.id).unwrap(), known.id);
1223
1224 let recovered = resolve_recovery_workspace_id("workspace-from-old-controller").unwrap();
1225 let repeated = resolve_recovery_workspace_id("another-old-workspace").unwrap();
1226 assert_eq!(repeated, recovered);
1227 assert_eq!(
1228 crate::database::list_workspaces()
1229 .unwrap()
1230 .iter()
1231 .filter(|workspace| workspace.name == "Recovered")
1232 .count(),
1233 1
1234 );
1235
1236 let session_id = "0123456789abcdef0123456789abcdef";
1237 let workers = tempfile::tempdir().unwrap();
1238 let record = adopted_session_record(
1239 session_id,
1240 "local-bare",
1241 "codex".into(),
1242 HarnessKind::Codex,
1243 "project".into(),
1244 recovered.clone(),
1245 TargetLocator::LocalBare {
1246 worker_root: workers.path().join(session_id),
1247 },
1248 );
1249 crate::database::save_session(&record).unwrap();
1250 assert_eq!(
1251 crate::database::load_state().unwrap().sessions[session_id].workspace_id,
1252 recovered
1253 );
1254
1255 let mut state = State::default();
1259 state.sessions.insert(session_id.to_owned(), record);
1260 let mut config = Config::default();
1261 config
1262 .targets
1263 .insert("local-bare".into(), TargetTemplate::LocalBare);
1264 let mut controller = Controller { config, state };
1265 let failure = controller
1266 .adopt_orphan_worker(
1267 session_id,
1268 "local-bare",
1269 None,
1270 None,
1271 false,
1272 &ProcessExecutor,
1273 )
1274 .await
1275 .expect_err("a worker root without a worker cannot complete the handshake");
1276 assert!(
1277 format!("{failure:#}").contains("orphan relay"),
1278 "unexpected failure: {failure:#}"
1279 );
1280 let stored = crate::database::load_state().unwrap();
1281 assert_eq!(stored.sessions[session_id].workspace_id, recovered);
1282 assert!(
1283 stored.sessions[session_id]
1284 .last_error
1285 .as_deref()
1286 .is_some_and(|error| error.contains("orphan adoption failed"))
1287 );
1288 }
1289
1290 #[test]
1291 fn a_session_that_completed_its_handshake_is_not_adoptable_again() {
1292 let mut record = adopted_session_record(
1293 "0123456789abcdef0123456789abcdef",
1294 "local-bare",
1295 "codex".into(),
1296 HarnessKind::Codex,
1297 "project".into(),
1298 mj_core::workspace::DEFAULT_WORKSPACE_ID.to_owned(),
1299 TargetLocator::LocalBare {
1300 worker_root: std::path::PathBuf::from("/workers/0123456789abcdef0123456789abcdef"),
1301 },
1302 );
1303 record.native_session_id = Some("native-session".into());
1304 assert!(!adoption_unfinished(&record, "local-bare"));
1305
1306 record.native_session_id = None;
1307 assert!(
1308 !adoption_unfinished(&record, "other-target"),
1309 "a record adopted onto another target is not this target's retry"
1310 );
1311 }
1312
1313 #[test]
1314 fn recovery_container_scan_requires_both_managed_and_session_labels() {
1315 let template = TargetTemplate::LocalPodman {
1316 container: ConfigContainer {
1317 build_cache: None,
1318 image: "ignored".into(),
1319 pull_policy: Default::default(),
1320 platform: None,
1321 cpus: None,
1322 memory: None,
1323 environment: BTreeMap::new(),
1324 workspace_storage: Default::default(),
1325 },
1326 };
1327 let json = serde_json::json!([
1328 {"Labels": {"dev.mj.managed": "true", "dev.mj.session": "0123456789abcdef0123456789abcdef", "dev.mj.instance": "qa0916"}},
1329 {"Labels": {"dev.mj.managed": "false", "dev.mj.session": "not-owned"}},
1330 {"configuration": {"labels": "dev.mj.managed=true,dev.mj.session=abcdef0123456789abcdef0123456789"}}
1331 ]);
1332 let candidates = candidates_from_container_json(
1333 "local",
1334 &template,
1335 serde_json::to_string(&json).unwrap().as_bytes(),
1336 )
1337 .unwrap();
1338 assert_eq!(candidates.len(), 2);
1339 assert_eq!(candidates[0].session_id, "0123456789abcdef0123456789abcdef");
1340 assert_eq!(candidates[0].instance_id.as_deref(), Some("qa0916"));
1341 assert_eq!(
1342 candidates[1].instance_id, None,
1343 "a container without an instance label is of unknown origin"
1344 );
1345 }
1346
1347 fn candidate(session_id: &str, instance_id: Option<&str>) -> RecoveryCandidate {
1348 RecoveryCandidate {
1349 session_id: session_id.to_owned(),
1350 target_template_id: "local".to_owned(),
1351 locator: TargetLocator::LocalDocker {
1352 container_id: format!("mj-{session_id}"),
1353 borrowed_from: None,
1354 },
1355 ownership: None,
1356 instance_id: instance_id.map(str::to_owned),
1357 }
1358 }
1359
1360 #[test]
1361 fn default_scan_scope_hides_other_and_unknown_instances() {
1362 let mut scan = RecoveryScan {
1363 candidates: vec![
1364 candidate("mine", Some("qa")),
1365 candidate("theirs", Some("prod")),
1366 candidate("legacy", None),
1367 ],
1368 instance_id: "qa".to_owned(),
1369 ..RecoveryScan::default()
1370 };
1371 restrict_to_instance(&mut scan);
1372 assert_eq!(
1373 scan.candidates
1374 .iter()
1375 .map(|candidate| candidate.session_id.as_str())
1376 .collect::<Vec<_>>(),
1377 ["mine"]
1378 );
1379 assert_eq!(scan.hidden_other_instances, 2);
1380 }
1381
1382 #[test]
1383 fn acting_on_another_or_unknown_instance_requires_the_explicit_flag() {
1384 require_instance_access(&candidate("mine", Some("qa")), "qa", false).unwrap();
1385
1386 let other = require_instance_access(&candidate("theirs", Some("prod")), "qa", false)
1387 .expect_err("another instance's worker is refused by default");
1388 assert!(
1389 other.to_string().contains("belongs to instance \"prod\""),
1390 "{other}"
1391 );
1392 require_instance_access(&candidate("theirs", Some("prod")), "qa", true).unwrap();
1393
1394 let unknown = require_instance_access(&candidate("legacy", None), "qa", false)
1395 .expect_err("a worker without a stamp is refused by default");
1396 assert!(
1397 unknown.to_string().contains("no instance stamp"),
1398 "{unknown}"
1399 );
1400 require_instance_access(&candidate("legacy", None), "qa", true).unwrap();
1401 }
1402
1403 #[test]
1404 fn a_podman_orphan_destroy_plan_removes_its_workspace_volume() {
1405 let session = "0123456789abcdef0123456789abcdef";
1409 let template = TargetTemplate::LocalPodman {
1410 container: ConfigContainer {
1411 image: "ignored".into(),
1412 pull_policy: Default::default(),
1413 platform: None,
1414 cpus: None,
1415 memory: None,
1416 environment: BTreeMap::new(),
1417 workspace_storage: PodmanWorkspaceStorage::PodmanVolume,
1418 build_cache: None,
1419 },
1420 };
1421 let json = serde_json::json!([
1422 {"Labels": {"dev.mj.managed": "true", "dev.mj.session": session}}
1423 ]);
1424
1425 let candidates = candidates_from_container_json(
1426 "local",
1427 &template,
1428 serde_json::to_string(&json).unwrap().as_bytes(),
1429 )
1430 .unwrap();
1431
1432 let [candidate] = candidates.as_slice() else {
1433 panic!("expected one orphan candidate, got {candidates:?}");
1434 };
1435 let volume = format!("{}-workspace", targets::resource_name(session).unwrap());
1436 assert!(
1437 matches!(
1438 &candidate.locator,
1439 TargetLocator::LocalPodman {
1440 workspace_storage: PodmanWorkspaceLocator::Volume { name },
1441 ..
1442 } if name == &volume
1443 ),
1444 "candidate locator lost the volume storage: {:?}",
1445 candidate.locator
1446 );
1447 let backend = recovery_backend_locator(&template, &candidate.locator, session).unwrap();
1448 let plan = targets::close_plan(&backend, session).unwrap();
1449 assert!(
1450 plan.commands.iter().any(|command| {
1451 command.args.iter().any(|argument| argument == &volume)
1452 && command
1453 .args
1454 .iter()
1455 .any(|argument| argument.contains("podman volume rm"))
1456 }),
1457 "destroy plan does not remove the workspace volume: {plan:?}"
1458 );
1459 }
1460
1461 #[test]
1462 fn recovery_docker_scan_accepts_json_lines_and_builds_a_docker_locator() {
1463 let template = TargetTemplate::LocalDocker {
1464 container: ConfigContainer {
1465 build_cache: None,
1466 image: "ignored".into(),
1467 pull_policy: Default::default(),
1468 platform: None,
1469 cpus: None,
1470 memory: None,
1471 environment: BTreeMap::new(),
1472 workspace_storage: Default::default(),
1473 },
1474 };
1475 let session = "0123456789abcdef0123456789abcdef";
1476 let output = format!(
1477 "{{\"Labels\":\"dev.mj.managed=true,dev.mj.session={session},dev.mj.instance=abc123\"}}\n{{\"Labels\":\"dev.mj.managed=false,dev.mj.session=ignored\"}}\n"
1478 );
1479
1480 let candidates =
1481 candidates_from_container_json("docker", &template, output.as_bytes()).unwrap();
1482
1483 assert_eq!(candidates.len(), 1);
1484 assert_eq!(candidates[0].session_id, session);
1485 assert_eq!(candidates[0].instance_id.as_deref(), Some("abc123"));
1486 assert!(matches!(
1487 &candidates[0].locator,
1488 TargetLocator::LocalDocker { container_id, .. }
1489 if container_id == &targets::resource_name(session).unwrap()
1490 ));
1491 }
1492
1493 #[test]
1494 fn recovery_aws_scan_uses_exact_tagged_instance_and_address() {
1495 let json = serde_json::json!({"Reservations": [{"Instances": [{
1496 "InstanceId": "i-exact",
1497 "PrivateIpAddress": "10.0.0.7",
1498 "Tags": [
1499 {"Key": "dev.mj.managed", "Value": "true"},
1500 {"Key": "dev.mj.session", "Value": "0123456789abcdef0123456789abcdef"},
1501 {"Key": "dev.mj.instance", "Value": "qa0916"}
1502 ]
1503 }]}]});
1504 let candidates = candidates_from_aws_json(
1505 "aws",
1506 AwsAddressSource::PrivateIp,
1507 serde_json::to_string(&json).unwrap().as_bytes(),
1508 )
1509 .unwrap();
1510 assert_eq!(candidates[0].instance_id.as_deref(), Some("qa0916"));
1511 assert!(matches!(
1512 &candidates[0].locator,
1513 TargetLocator::AwsEc2 { instance_id, address }
1514 if instance_id == "i-exact" && address.as_deref() == Some("10.0.0.7")
1515 ));
1516 }
1517}