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