Skip to main content

mj_controller/controller/
recovery_scan.rs

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