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