Skip to main content

mj_controller/controller/
backend.rs

1//! Backend target, locator, and capacity conversion for provisioned sessions.
2
3use std::collections::BTreeMap;
4use std::path::PathBuf;
5use std::process::{Command, Stdio};
6use std::time::{Duration, Instant};
7
8use anyhow::{Context, Result, bail, ensure};
9
10use mj_core::config::{
11    AwsAddressSource, Config, PodmanWorkspaceStorage, ProjectBundle, TargetTemplate, data_dir,
12};
13use mj_core::state::{
14    PodmanWorkspaceLocator, SessionRecord, SessionResourceAllocation, TargetLocator,
15    allocation_cpus,
16};
17
18use crate::targets::{
19    self, AwsTemplate, CommandExecutor, CommandOutput, CommandSpec, ContainerTemplate, ImageHost,
20    ImageRefresh, ProjectBundleSpec, ProvisionStage, RepositorySpec, SshTarget,
21};
22
23use super::{Controller, backend_ssh, execute_checked, ssh_args_with_identity, ssh_command_spec};
24
25impl Controller {
26    pub fn resolve_aws_resource_options(
27        &self,
28        target_id: &str,
29        executor: &impl CommandExecutor,
30    ) -> Result<Vec<SessionResourceAllocation>> {
31        let TargetTemplate::AwsEc2 {
32            aws_profile,
33            region,
34            launch_template,
35            launch_template_version,
36            ..
37        } = self
38            .config
39            .targets
40            .get(target_id)
41            .with_context(|| format!("unknown target template {target_id:?}"))?
42        else {
43            bail!("target {target_id:?} is not an AWS EC2 target");
44        };
45        let profile = aws_profile.as_deref().unwrap_or("default");
46        let launch_key = if launch_template.starts_with("lt-") {
47            "--launch-template-id"
48        } else {
49            "--launch-template-name"
50        };
51        let version = launch_template_version.as_deref().unwrap_or("$Default");
52        let describe_template = CommandSpec::new(
53            "aws",
54            [
55                "--profile",
56                profile,
57                "--region",
58                region,
59                "ec2",
60                "describe-launch-template-versions",
61                launch_key,
62                launch_template,
63                "--versions",
64                version,
65                "--output",
66                "json",
67            ],
68        )
69        .purpose("resolve EC2 launch template instance family");
70        let output = executor.execute(&describe_template)?;
71        if output.status != 0 {
72            bail!(
73                "{} failed with status {}: {}",
74                describe_template.purpose,
75                output.status,
76                String::from_utf8_lossy(&output.stderr).trim()
77            );
78        }
79        let response: serde_json::Value =
80            serde_json::from_slice(&output.stdout).context("parse EC2 launch template response")?;
81        let instance_type = response
82            .pointer("/LaunchTemplateVersions/0/LaunchTemplateData/InstanceType")
83            .and_then(serde_json::Value::as_str)
84            .context("launch template does not specify a concrete instance type")?;
85        let family = instance_type
86            .rsplit_once('.')
87            .map(|(family, _)| family)
88            .context("launch template instance type has no size suffix")?;
89        let filter = format!("Name=instance-type,Values={family}.*");
90        let describe_types = CommandSpec::new(
91            "aws",
92            [
93                "--profile",
94                profile,
95                "--region",
96                region,
97                "ec2",
98                "describe-instance-types",
99                "--filters",
100                &filter,
101                "--output",
102                "json",
103            ],
104        )
105        .purpose("discover EC2 instance sizes");
106        let output = executor.execute(&describe_types)?;
107        if output.status != 0 {
108            bail!(
109                "{} failed with status {}: {}",
110                describe_types.purpose,
111                output.status,
112                String::from_utf8_lossy(&output.stderr).trim()
113            );
114        }
115        let response: serde_json::Value =
116            serde_json::from_slice(&output.stdout).context("parse EC2 instance type response")?;
117        let mut options = response
118            .get("InstanceTypes")
119            .and_then(serde_json::Value::as_array)
120            .context("EC2 instance type response omitted InstanceTypes")?
121            .iter()
122            .filter_map(|entry| {
123                Some(SessionResourceAllocation::AwsEc2 {
124                    instance_type: entry.get("InstanceType")?.as_str()?.to_owned(),
125                    vcpus: entry.pointer("/VCpuInfo/DefaultVCpus")?.as_u64()?,
126                    memory_bytes: entry
127                        .pointer("/MemoryInfo/SizeInMiB")?
128                        .as_u64()?
129                        .checked_mul(1024 * 1024)?,
130                })
131            })
132            .collect::<Vec<_>>();
133        options.sort_by_key(allocation_cpus);
134        if !options.iter().any(|option| allocation_cpus(option) == 8) {
135            bail!("EC2 family {family:?} has no exact 8-vCPU baseline size");
136        }
137        Ok(options)
138    }
139
140    pub fn reconnect_command(&self, session_id: &str) -> Result<CommandSpec> {
141        let session = self
142            .state
143            .sessions
144            .get(session_id)
145            .with_context(|| format!("unknown session {session_id}"))?;
146        session.validate_configuration(&self.config)?;
147        let locator = session.target.as_ref().context("session has no target")?;
148        let backend = backend_locator(locator, session, &self.config)?;
149        targets::reconnect_plan(&backend, session_id)?
150            .commands
151            .into_iter()
152            .next()
153            .context("reconnect plan is empty")
154    }
155
156    pub fn resource_probe(&self, session_id: &str) -> Result<targets::SessionResourceProbe> {
157        let session = self
158            .state
159            .sessions
160            .get(session_id)
161            .with_context(|| format!("unknown session {session_id}"))?;
162        let locator = session.target.as_ref().context("session has no target")?;
163        let backend = backend_locator(locator, session, &self.config)?;
164        targets::resource_probe(&backend, session_id)
165    }
166
167    pub fn deployment_capacity_targets(&self) -> Vec<targets::DeploymentCapacityTarget> {
168        use targets::{DeploymentCapacityKind, DeploymentCapacityTarget};
169
170        let mut local_ids = Vec::new();
171        let mut ssh_hosts: BTreeMap<String, (Vec<String>, Vec<CommandSpec>)> = BTreeMap::new();
172        let mut targets = Vec::new();
173        for (target_id, template) in &self.config.targets {
174            match template {
175                TargetTemplate::LocalBare
176                | TargetTemplate::LocalPodman { .. }
177                | TargetTemplate::LocalDocker { .. }
178                | TargetTemplate::AppleContainer { .. } => {
179                    local_ids.push(target_id.clone());
180                }
181                TargetTemplate::SshBare { ssh, .. }
182                | TargetTemplate::SshPodman { ssh, .. }
183                | TargetTemplate::SshDocker { ssh, .. } => {
184                    let entry = ssh_hosts.entry(ssh.host.clone()).or_default();
185                    entry.0.push(target_id.clone());
186                    let command = targets::ssh_host_capacity_command(&backend_ssh(ssh));
187                    if !entry.1.contains(&command) {
188                        entry.1.push(command);
189                    }
190                }
191                TargetTemplate::AwsEc2 { .. } => {
192                    let mut probes = Vec::new();
193                    let mut probe_error = None;
194                    for session in self.state.sessions.values().filter(|session| {
195                        session.target_template_id == *target_id
196                            && session.state.is_active()
197                            && session.target.is_some()
198                    }) {
199                        let result = backend_locator(
200                            session.target.as_ref().expect("filtered target"),
201                            session,
202                            &self.config,
203                        )
204                        .and_then(|locator| {
205                            targets::aws_allocated_capacity_command(&locator, &session.id)
206                        });
207                        match result {
208                            Ok(command) => probes.push(command),
209                            Err(error) => probe_error = Some(format!("{error:#}")),
210                        }
211                    }
212                    targets.push(DeploymentCapacityTarget {
213                        id: format!("aws:{target_id}"),
214                        host: target_id.clone(),
215                        target_ids: vec![target_id.clone()],
216                        kind: DeploymentCapacityKind::AwsFleet,
217                        local: false,
218                        probes,
219                        probe_error,
220                    });
221                }
222            }
223        }
224        if !local_ids.is_empty() {
225            targets.push(DeploymentCapacityTarget {
226                id: "local".into(),
227                host: "local".into(),
228                target_ids: local_ids,
229                kind: DeploymentCapacityKind::Host,
230                local: true,
231                probes: Vec::new(),
232                probe_error: None,
233            });
234        }
235        targets.extend(ssh_hosts.into_iter().map(|(host, (target_ids, probes))| {
236            DeploymentCapacityTarget {
237                id: format!("ssh:{host}"),
238                host,
239                target_ids,
240                kind: DeploymentCapacityKind::Host,
241                local: false,
242                probes,
243                probe_error: None,
244            }
245        }));
246        targets.sort_by(|left, right| left.id.cmp(&right.id));
247        targets
248    }
249
250    pub fn test_target(&self, target_id: &str, executor: &impl CommandExecutor) -> Result<()> {
251        let template = self
252            .config
253            .targets
254            .get(target_id)
255            .with_context(|| format!("unknown target template {target_id:?}"))?;
256        preflight_target(template, executor)
257    }
258}
259
260pub(super) fn preflight_target(
261    template: &TargetTemplate,
262    executor: &impl CommandExecutor,
263) -> Result<()> {
264    match template {
265        TargetTemplate::LocalPodman { .. } => targets::verify_local_podman(executor)
266            .map(|_| ())
267            .map_err(|error| {
268                anyhow::anyhow!(
269                    "local Podman preflight failed; run `mj doctor` for actionable prerequisites: {error:#}"
270                )
271            }),
272        TargetTemplate::LocalDocker { .. } => targets::verify_local_docker(executor)
273            .map(|_| ())
274            .map_err(|error| {
275                anyhow::anyhow!(
276                    "local Docker preflight failed; run `mj doctor` for actionable prerequisites: {error:#}"
277                )
278            }),
279        TargetTemplate::SshPodman { ssh, .. } => {
280            let ssh = backend_ssh(ssh);
281            targets::verify_ssh_podman(&ssh, executor)
282                .map(|preflight| {
283                    for warning in preflight.warnings {
284                        executor.notify_notice(&warning.notice());
285                    }
286                })
287                .map_err(|error| {
288                    anyhow::anyhow!(
289                        "remote Podman preflight failed for {}; run `mj doctor` for actionable prerequisites: {error:#}",
290                        ssh.destination
291                    )
292                })
293        }
294        TargetTemplate::SshDocker { ssh, .. } => {
295            let ssh = backend_ssh(ssh);
296            targets::verify_ssh_docker(&ssh, executor)
297                .map(|_| ())
298                .map_err(|error| {
299                    anyhow::anyhow!(
300                        "remote Docker preflight failed for {}; run `mj doctor` for actionable prerequisites: {error:#}",
301                        ssh.destination
302                    )
303                })
304        }
305        TargetTemplate::AppleContainer { .. } => {
306            let command = CommandSpec::new("container", ["system", "status"])
307                .purpose("preflight Apple container runtime")
308                .stage(ProvisionStage::Provisioning);
309            let output = executor.execute(&command).map_err(|error| {
310                anyhow::anyhow!(
311                    "Apple container preflight failed; run `mj doctor` for actionable prerequisites: {error}"
312                )
313            })?;
314            if output.status != 0 {
315                bail!(
316                    "Apple container preflight failed; run `mj doctor` for actionable prerequisites: container system status exited {}: {}",
317                    output.status,
318                    String::from_utf8_lossy(&output.stderr).trim()
319                );
320            }
321            Ok(())
322        }
323        TargetTemplate::SshBare { ssh, .. } => {
324            let ssh = backend_ssh(ssh);
325            let command = targets::ssh_connectivity_probe(&ssh);
326            let output = executor.execute(&command)?;
327            ensure!(
328                output.status == 0,
329                "SSH connectivity test failed for {} with status {}: {}",
330                ssh.destination,
331                output.status,
332                String::from_utf8_lossy(&output.stderr).trim()
333            );
334            Ok(())
335        }
336        TargetTemplate::AwsEc2 {
337            aws_profile,
338            region,
339            launch_template,
340            launch_template_version,
341            ..
342        } => {
343            let mut identity_args = vec!["sts".into(), "get-caller-identity".into()];
344            if let Some(profile) = aws_profile {
345                identity_args.extend(["--profile".into(), profile.clone()]);
346            }
347            let identity = CommandSpec::new("aws", identity_args)
348                .purpose("verify AWS credentials")
349                .stage(ProvisionStage::Provisioning);
350            let output = executor.execute(&identity)?;
351            ensure!(
352                output.status == 0,
353                "AWS credential test failed with status {}: {}",
354                output.status,
355                String::from_utf8_lossy(&output.stderr).trim()
356            );
357
358            let mut launch_args = vec![
359                "ec2".into(),
360                "describe-launch-template-versions".into(),
361                "--region".into(),
362                region.clone(),
363                "--launch-template-name".into(),
364                launch_template.clone(),
365                "--versions".into(),
366                launch_template_version
367                    .clone()
368                    .unwrap_or_else(|| "$Default".into()),
369            ];
370            if let Some(profile) = aws_profile {
371                launch_args.extend(["--profile".into(), profile.clone()]);
372            }
373            let launch = CommandSpec::new("aws", launch_args)
374                .purpose("verify AWS launch template")
375                .stage(ProvisionStage::Provisioning);
376            let output = executor.execute(&launch)?;
377            ensure!(
378                output.status == 0,
379                "AWS launch-template test failed with status {}: {}",
380                output.status,
381                String::from_utf8_lossy(&output.stderr).trim()
382            );
383            Ok(())
384        }
385        TargetTemplate::LocalBare => Ok(()),
386    }
387}
388
389pub(super) fn backend_bundle(
390    bundle: &ProjectBundle,
391    executor: &impl CommandExecutor,
392) -> Result<ProjectBundleSpec> {
393    let primary = bundle.primary().context("bundle primary is missing")?;
394    Ok(ProjectBundleSpec {
395        primary: primary.destination.to_string_lossy().into_owned(),
396        repositories: bundle
397            .repositories
398            .iter()
399            .map(|repository| {
400                let source = mj_core::remote_git::resolve_repository(repository, executor)
401                    .with_context(|| format!("repository {:?}", repository.id))?;
402                Ok(RepositorySpec {
403                    url: Some(source.fetch_url),
404                    push_urls: source.push_urls,
405                    destination: repository.destination.to_string_lossy().into_owned(),
406                    git_ref: None,
407                    reference: None,
408                })
409            })
410            .collect::<Result<Vec<_>>>()?,
411    })
412}
413
414/// Per-session container size overrides. They win over both the target
415/// template's values and any recorded resource allocation, and they are read
416/// only while a container is being created.
417#[derive(Debug, Clone, Copy, Default)]
418pub(super) struct ContainerOverrides<'a> {
419    pub cpus: Option<&'a str>,
420    pub memory: Option<&'a str>,
421}
422
423impl<'a> ContainerOverrides<'a> {
424    pub(super) fn for_session(session: &'a SessionRecord) -> Self {
425        Self {
426            cpus: session.container_cpus.as_deref(),
427            memory: session.container_memory.as_deref(),
428        }
429    }
430}
431
432pub(super) fn backend_target(
433    template: &TargetTemplate,
434    allocation: Option<&SessionResourceAllocation>,
435    overrides: ContainerOverrides<'_>,
436) -> Result<targets::TargetTemplate> {
437    Ok(match template {
438        TargetTemplate::LocalBare => targets::TargetTemplate::LocalBare,
439        TargetTemplate::LocalPodman { container } => {
440            let mut backend = backend_container(container, allocation, overrides);
441            backend.workspace_storage = backend_workspace_storage(&container.workspace_storage);
442            targets::TargetTemplate::LocalPodman(backend)
443        }
444        TargetTemplate::LocalDocker { container } => targets::TargetTemplate::LocalDocker(
445            backend_container(container, allocation, overrides),
446        ),
447        TargetTemplate::AppleContainer { container } => targets::TargetTemplate::AppleContainer(
448            backend_container(container, allocation, overrides),
449        ),
450        TargetTemplate::AwsEc2 {
451            aws_profile,
452            region,
453            launch_template,
454            launch_template_version,
455            ssh_user,
456            identity_file,
457            ssh_args,
458            ..
459        } => targets::TargetTemplate::AwsEc2(AwsTemplate {
460            profile: aws_profile.clone().unwrap_or_else(|| "default".into()),
461            region: region.clone(),
462            launch_template: launch_template.clone(),
463            launch_template_version: launch_template_version.clone(),
464            instance_type: match allocation {
465                Some(SessionResourceAllocation::AwsEc2 { instance_type, .. }) => {
466                    Some(instance_type.clone())
467                }
468                _ => None,
469            },
470            // The address is filled after describe-instances.
471            ssh: SshTarget {
472                destination: format!("{ssh_user}@pending.invalid"),
473                ssh_args: ssh_args_with_identity(ssh_args, identity_file.as_deref()),
474            },
475        }),
476        TargetTemplate::SshBare {
477            ssh,
478            workspace_prefix,
479            ..
480        } => targets::TargetTemplate::SshBare {
481            ssh: backend_ssh(ssh),
482            workspace_prefix: workspace_prefix.to_string_lossy().into_owned(),
483        },
484        TargetTemplate::SshPodman { ssh, container, .. } => {
485            let mut backend = backend_container(container, allocation, overrides);
486            backend.workspace_storage = backend_workspace_storage(&container.workspace_storage);
487            targets::TargetTemplate::SshPodman {
488                ssh: backend_ssh(ssh),
489                container: backend,
490            }
491        }
492        TargetTemplate::SshDocker { ssh, container, .. } => targets::TargetTemplate::SshDocker {
493            ssh: backend_ssh(ssh),
494            container: backend_container(container, allocation, overrides),
495        },
496    })
497}
498
499/// Every container image a background refresh keeps current, once per
500/// (host, image, platform).
501///
502/// Targets the host can already satisfy are left out: digest pins, versioned
503/// tags, and the explicit `missing` and `never` policies. Apple's `container`
504/// engine is left out too; it still refreshes its image during provisioning.
505/// Several targets often share one image on one host, and that needs one pull.
506pub fn image_refresh_plan(config: &Config) -> Vec<ImageRefresh> {
507    let mut plan: Vec<ImageRefresh> = Vec::new();
508    for target in config.targets.values() {
509        let (host, container) = match target {
510            TargetTemplate::LocalPodman { container } => (ImageHost::LocalPodman, container),
511            TargetTemplate::LocalDocker { container } => (ImageHost::LocalDocker, container),
512            TargetTemplate::SshPodman { ssh, container } => {
513                (ImageHost::SshPodman(backend_ssh(ssh)), container)
514            }
515            TargetTemplate::SshDocker { ssh, container } => {
516                (ImageHost::SshDocker(backend_ssh(ssh)), container)
517            }
518            TargetTemplate::LocalBare
519            | TargetTemplate::AppleContainer { .. }
520            | TargetTemplate::AwsEc2 { .. }
521            | TargetTemplate::SshBare { .. } => continue,
522        };
523        // Commands are decided by the host, the image, and the platform alone,
524        // so equal refreshes are exactly the duplicates worth collapsing.
525        let Some(refresh) = targets::image_refresh(
526            host,
527            &container.image,
528            container.platform.as_deref(),
529            container.pull_policy,
530        ) else {
531            continue;
532        };
533        if !plan.contains(&refresh) {
534            plan.push(refresh);
535        }
536    }
537    plan
538}
539
540pub(crate) fn controller_github_token() -> Option<String> {
541    for name in ["GH_TOKEN", "GITHUB_TOKEN"] {
542        if let Ok(token) = std::env::var(name)
543            && let Some(token) = usable_github_token(&token)
544        {
545            return Some(token.to_owned());
546        }
547    }
548    let output = match Command::new("gh")
549        .args(["auth", "token", "--hostname", "github.com"])
550        .stdin(Stdio::null())
551        .stderr(Stdio::null())
552        .output()
553    {
554        Ok(output) => output,
555        Err(error) => {
556            tracing::debug!(%error, "could not query the GitHub CLI for a token");
557            return None;
558        }
559    };
560    if !output.status.success() {
561        tracing::debug!(status = ?output.status, "GitHub CLI did not return an authenticated token");
562        return None;
563    }
564    let token = match std::str::from_utf8(&output.stdout) {
565        Ok(token) => token,
566        Err(error) => {
567            tracing::debug!(%error, "GitHub CLI returned a non-UTF-8 token");
568            return None;
569        }
570    };
571    let Some(token) = usable_github_token(token) else {
572        tracing::debug!("GitHub CLI returned an empty or invalid token");
573        return None;
574    };
575    Some(token.to_owned())
576}
577
578fn usable_github_token(token: &str) -> Option<&str> {
579    let token = token.trim();
580    (!token.is_empty() && !token.chars().any(char::is_whitespace)).then_some(token)
581}
582
583pub(super) fn configure_github_token_environment(target: &mut targets::TargetTemplate) -> bool {
584    let container = match target {
585        targets::TargetTemplate::LocalPodman(container)
586        | targets::TargetTemplate::LocalDocker(container)
587        | targets::TargetTemplate::AppleContainer(container)
588        | targets::TargetTemplate::SshPodman { container, .. }
589        | targets::TargetTemplate::SshDocker { container, .. } => container,
590        targets::TargetTemplate::LocalBare
591        | targets::TargetTemplate::AwsEc2(_)
592        | targets::TargetTemplate::SshBare { .. } => return false,
593    };
594    container
595        .extra_run_args
596        .extend(["--env".to_owned(), "GH_TOKEN".to_owned()]);
597    true
598}
599
600pub(super) fn use_github_https_urls(bundle: &mut targets::ProjectBundleSpec) {
601    for repository in &mut bundle.repositories {
602        for source in repository
603            .url
604            .iter_mut()
605            .chain(repository.push_urls.iter_mut())
606        {
607            if let Some(github) = crate::setup::github_repository_from_origin(source) {
608                *source = format!(
609                    "https://github.com/{}/{}.git",
610                    github.owner, github.repository
611                );
612            }
613        }
614    }
615}
616
617fn backend_container(
618    container: &mj_core::config::ContainerTemplate,
619    allocation: Option<&SessionResourceAllocation>,
620    overrides: ContainerOverrides<'_>,
621) -> ContainerTemplate {
622    let mut extra_run_args = Vec::new();
623    if let Some(platform) = &container.platform {
624        extra_run_args.push(format!("--platform={platform}"));
625    }
626    let (cpus, memory) = match allocation {
627        Some(SessionResourceAllocation::Container { cpus, memory_bytes }) => {
628            (Some(cpus.to_string()), Some(memory_bytes.to_string()))
629        }
630        _ => (container.cpus.clone(), container.memory.clone()),
631    };
632    // The session's own overrides are the last word on size.
633    let cpus = overrides.cpus.map(str::to_owned).or(cpus);
634    let memory = overrides.memory.map(str::to_owned).or(memory);
635    if let Some(cpus) = cpus {
636        extra_run_args.push(format!("--cpus={cpus}"));
637    }
638    if let Some(memory) = memory {
639        extra_run_args.push(format!("--memory={memory}"));
640    }
641    for (key, value) in &container.environment {
642        extra_run_args.extend(["--env".to_string(), format!("{key}={value}")]);
643    }
644    ContainerTemplate {
645        image: container.image.clone(),
646        pull_policy: container.pull_policy,
647        extra_run_args,
648        workspace_storage: targets::PodmanWorkspaceStorage::ContainerLayer,
649    }
650}
651
652fn backend_workspace_storage(storage: &PodmanWorkspaceStorage) -> targets::PodmanWorkspaceStorage {
653    match storage {
654        PodmanWorkspaceStorage::PodmanVolume => targets::PodmanWorkspaceStorage::PodmanVolume,
655        PodmanWorkspaceStorage::HostHelper { root, helper } => {
656            targets::PodmanWorkspaceStorage::HostHelper {
657                root: root.to_string_lossy().into_owned(),
658                helper: helper.clone(),
659            }
660        }
661        PodmanWorkspaceStorage::ContainerLayer => targets::PodmanWorkspaceStorage::ContainerLayer,
662    }
663}
664
665fn backend_workspace_locator(storage: &PodmanWorkspaceLocator) -> targets::PodmanWorkspaceLocator {
666    match storage {
667        PodmanWorkspaceLocator::ContainerLayer => targets::PodmanWorkspaceLocator::ContainerLayer,
668        PodmanWorkspaceLocator::Volume { name } => {
669            targets::PodmanWorkspaceLocator::Volume { name: name.clone() }
670        }
671        PodmanWorkspaceLocator::HostPath {
672            path,
673            helper,
674            resource,
675        } => targets::PodmanWorkspaceLocator::HostPath {
676            path: path.to_string_lossy().into_owned(),
677            helper: helper.clone(),
678            resource: resource.clone(),
679        },
680    }
681}
682
683fn durable_workspace_locator(storage: targets::PodmanWorkspaceLocator) -> PodmanWorkspaceLocator {
684    match storage {
685        targets::PodmanWorkspaceLocator::ContainerLayer => PodmanWorkspaceLocator::ContainerLayer,
686        targets::PodmanWorkspaceLocator::Volume { name } => PodmanWorkspaceLocator::Volume { name },
687        targets::PodmanWorkspaceLocator::HostPath {
688            path,
689            helper,
690            resource,
691        } => PodmanWorkspaceLocator::HostPath {
692            path: PathBuf::from(path),
693            helper,
694            resource,
695        },
696    }
697}
698
699pub(super) fn validate_resource_allocation(
700    template: &TargetTemplate,
701    allocation: Option<&SessionResourceAllocation>,
702) -> Result<()> {
703    if let Some(allocation) = allocation {
704        allocation.validate()?;
705    }
706    match (template, allocation) {
707        (_, None)
708        | (
709            TargetTemplate::LocalPodman { .. }
710            | TargetTemplate::LocalDocker { .. }
711            | TargetTemplate::AppleContainer { .. }
712            | TargetTemplate::SshPodman { .. }
713            | TargetTemplate::SshDocker { .. },
714            Some(SessionResourceAllocation::Container { .. }),
715        )
716        | (TargetTemplate::AwsEc2 { .. }, Some(SessionResourceAllocation::AwsEc2 { .. })) => Ok(()),
717        (TargetTemplate::LocalBare | TargetTemplate::SshBare { .. }, Some(_)) => {
718            bail!("bare targets have fixed host resources")
719        }
720        _ => bail!("resource allocation does not match the selected target kind"),
721    }
722}
723
724/// How long a freshly launched EC2 instance may take to accept SSH.
725const AWS_SSH_READY_TIMEOUT: Duration = Duration::from_secs(300);
726
727const AWS_SSH_READY_RETRY_DELAY: Duration = Duration::from_secs(3);
728
729/// Poll a remote host until it accepts SSH, or until the deadline passes.
730///
731/// `now` and `sleep` are injected so tests can drive the deadline without
732/// waiting in real time.
733fn wait_for_ssh_ready(
734    executor: &impl CommandExecutor,
735    probe: &CommandSpec,
736    timeout: Duration,
737    mut now: impl FnMut() -> Instant,
738    mut sleep: impl FnMut(Duration),
739) -> Result<()> {
740    let started = now();
741    loop {
742        if executor.cancellation_requested() {
743            bail!("cancelled while waiting for SSH on the new instance");
744        }
745        let failure = match executor.execute(probe) {
746            Ok(output) if output.status == 0 => return Ok(()),
747            Ok(output) => String::from_utf8_lossy(&output.stderr).trim().to_string(),
748            Err(error) => error.to_string(),
749        };
750        if now().duration_since(started) >= timeout {
751            bail!(
752                "{} timed out after {}s: {}",
753                probe.purpose,
754                timeout.as_secs(),
755                if failure.is_empty() {
756                    "the SSH probe reported no error output"
757                } else {
758                    failure.as_str()
759                }
760            );
761        }
762        sleep(AWS_SSH_READY_RETRY_DELAY);
763    }
764}
765
766pub(super) fn locator_after_provision(
767    canonical: &TargetTemplate,
768    backend: &targets::TargetTemplate,
769    session_id: &str,
770    first_output: Option<&CommandOutput>,
771    executor: &(impl CommandExecutor + Sync),
772) -> Result<TargetLocator> {
773    let generated = targets::resource_name(session_id)?;
774    Ok(match canonical {
775        TargetTemplate::LocalBare => TargetLocator::LocalBare {
776            worker_root: data_dir().join("workers").join(session_id),
777        },
778        TargetTemplate::LocalPodman { .. } => {
779            let targets::TargetTemplate::LocalPodman(container) = backend else {
780                bail!("session locator/template mismatch")
781            };
782            TargetLocator::LocalPodman {
783                container_id: generated,
784                workspace_storage: durable_workspace_locator(targets::podman_workspace_locator(
785                    container, session_id,
786                )?),
787            }
788        }
789        TargetTemplate::LocalDocker { .. } => TargetLocator::LocalDocker {
790            container_id: generated,
791        },
792        TargetTemplate::AppleContainer { .. } => TargetLocator::AppleContainer {
793            container_id: generated,
794        },
795        TargetTemplate::SshBare { ssh, .. } => TargetLocator::SshBare {
796            host: ssh.host.clone(),
797            workspace: PathBuf::from(targets::workspace_for(backend, session_id)?),
798            worker_id: None,
799        },
800        TargetTemplate::SshPodman { ssh, .. } => {
801            let targets::TargetTemplate::SshPodman { container, .. } = backend else {
802                bail!("session locator/template mismatch")
803            };
804            TargetLocator::SshPodman {
805                host: ssh.host.clone(),
806                container_id: generated,
807                workspace_storage: durable_workspace_locator(targets::podman_workspace_locator(
808                    container, session_id,
809                )?),
810            }
811        }
812        TargetTemplate::SshDocker { ssh, .. } => TargetLocator::SshDocker {
813            host: ssh.host.clone(),
814            container_id: generated,
815        },
816        TargetTemplate::AwsEc2 {
817            aws_profile,
818            region,
819            ssh_user,
820            address_source,
821            identity_file,
822            ssh_args,
823            ..
824        } => {
825            let output = first_output.context("AWS launch produced no output")?;
826            let json: serde_json::Value = serde_json::from_slice(&output.stdout)
827                .context("parse aws ec2 run-instances response")?;
828            let instance_id = json
829                .pointer("/Instances/0/InstanceId")
830                .and_then(serde_json::Value::as_str)
831                .context("AWS response omitted instance ID")?
832                .to_string();
833            let profile = aws_profile.clone().unwrap_or_else(|| "default".into());
834            execute_checked(
835                executor,
836                CommandSpec::new(
837                    "aws",
838                    [
839                        "--profile".into(),
840                        profile.clone(),
841                        "--region".into(),
842                        region.clone(),
843                        "ec2".into(),
844                        "wait".into(),
845                        "instance-running".into(),
846                        "--instance-ids".into(),
847                        instance_id.clone(),
848                    ],
849                )
850                .purpose("wait for EC2 session instance to run")
851                .stage(ProvisionStage::Booting),
852            )?;
853            let field = match address_source {
854                AwsAddressSource::PublicDns => "PublicDnsName",
855                AwsAddressSource::PublicIp => "PublicIpAddress",
856                AwsAddressSource::PrivateDns => "PrivateDnsName",
857                AwsAddressSource::PrivateIp => "PrivateIpAddress",
858            };
859            let address = execute_checked(
860                executor,
861                CommandSpec::new(
862                    "aws",
863                    [
864                        "--profile".into(),
865                        profile.clone(),
866                        "--region".into(),
867                        region.clone(),
868                        "ec2".into(),
869                        "describe-instances".into(),
870                        "--instance-ids".into(),
871                        instance_id.clone(),
872                        "--query".into(),
873                        format!("Reservations[0].Instances[0].{field}"),
874                        "--output".into(),
875                        "text".into(),
876                    ],
877                )
878                .purpose("resolve EC2 session address")
879                .stage(ProvisionStage::Booting),
880            )?;
881            let address = String::from_utf8(address.stdout)
882                .context("AWS address was not UTF-8")?
883                .trim()
884                .to_string();
885            if address.is_empty() || address == "None" {
886                bail!("AWS instance {instance_id} has no configured address");
887            }
888            let ssh = SshTarget {
889                destination: format!("{ssh_user}@{address}"),
890                ssh_args: ssh_args_with_identity(ssh_args, identity_file.as_deref()),
891            };
892            wait_for_ssh_ready(
893                executor,
894                &ssh_command_spec(&ssh, ["true"])
895                    .purpose("wait for EC2 SSH availability")
896                    .stage(ProvisionStage::Booting),
897                AWS_SSH_READY_TIMEOUT,
898                Instant::now,
899                std::thread::sleep,
900            )?;
901            TargetLocator::AwsEc2 {
902                instance_id,
903                address: Some(address),
904            }
905        }
906    })
907}
908
909pub(super) fn backend_locator(
910    locator: &TargetLocator,
911    session: &SessionRecord,
912    config: &Config,
913) -> Result<targets::TargetLocator> {
914    let template = config
915        .targets
916        .get(&session.target_template_id)
917        .context("session target template is missing")?;
918    Ok(match locator {
919        TargetLocator::LocalBare { worker_root } => {
920            let TargetTemplate::LocalBare = template else {
921                bail!("session locator/template mismatch")
922            };
923            targets::TargetLocator::LocalBare {
924                worker_root: worker_root.to_string_lossy().into_owned(),
925            }
926        }
927        TargetLocator::LocalPodman {
928            container_id,
929            workspace_storage,
930        } => targets::TargetLocator::LocalPodman {
931            container_id: container_id.clone(),
932            workspace_storage: backend_workspace_locator(workspace_storage),
933        },
934        TargetLocator::LocalDocker { container_id } => targets::TargetLocator::LocalDocker {
935            container_id: container_id.clone(),
936        },
937        TargetLocator::AppleContainer { container_id } => targets::TargetLocator::AppleContainer {
938            container_id: container_id.clone(),
939        },
940        TargetLocator::SshBare {
941            workspace,
942            worker_id,
943            ..
944        } => {
945            let TargetTemplate::SshBare { ssh, .. } = template else {
946                bail!("session locator/template mismatch")
947            };
948            targets::TargetLocator::SshBare {
949                ssh: backend_ssh(ssh),
950                workspace: workspace.to_string_lossy().into_owned(),
951                worker_id: worker_id.clone(),
952            }
953        }
954        TargetLocator::SshPodman {
955            container_id,
956            workspace_storage,
957            ..
958        } => {
959            let TargetTemplate::SshPodman { ssh, .. } = template else {
960                bail!("session locator/template mismatch")
961            };
962            targets::TargetLocator::SshPodman {
963                ssh: backend_ssh(ssh),
964                container_id: container_id.clone(),
965                workspace_storage: backend_workspace_locator(workspace_storage),
966            }
967        }
968        TargetLocator::SshDocker { host, container_id } => {
969            let TargetTemplate::SshDocker { ssh, .. } = template else {
970                bail!("session locator/template mismatch")
971            };
972            ensure!(
973                host == &ssh.host,
974                "session locator/template SSH host mismatch"
975            );
976            targets::TargetLocator::SshDocker {
977                ssh: backend_ssh(ssh),
978                container_id: container_id.clone(),
979            }
980        }
981        TargetLocator::AwsEc2 {
982            instance_id,
983            address,
984        } => {
985            let TargetTemplate::AwsEc2 {
986                aws_profile,
987                region,
988                ssh_user,
989                identity_file,
990                ssh_args,
991                ..
992            } = template
993            else {
994                bail!("session locator/template mismatch")
995            };
996            let address = address.as_deref().context("AWS locator has no address")?;
997            targets::TargetLocator::AwsEc2 {
998                profile: aws_profile.clone().unwrap_or_else(|| "default".into()),
999                region: region.clone(),
1000                instance_id: instance_id.clone(),
1001                ssh: SshTarget {
1002                    destination: format!("{ssh_user}@{address}"),
1003                    ssh_args: ssh_args_with_identity(ssh_args, identity_file.as_deref()),
1004                },
1005                workspace: format!(".local/share/hel/workspaces/{}", session.id),
1006            }
1007        }
1008    })
1009}
1010
1011#[cfg(test)]
1012mod tests {
1013    use std::cell::RefCell;
1014    use std::collections::BTreeMap;
1015    use std::path::PathBuf;
1016    use std::time::{Duration, Instant};
1017
1018    use anyhow::Result;
1019
1020    use crate::controller::Controller;
1021    use crate::controller::provisioning::install_attached_resources;
1022    use mj_core::config::{
1023        AwsAddressSource, Config, ContainerTemplate as ConfigContainer, ProjectBundle,
1024        ProjectRepository, SshConnection, TargetTemplate,
1025    };
1026    use mj_core::state::{SessionRecord, SessionState, State};
1027
1028    use crate::targets::{
1029        self, AdditionalMount, CommandExecutor, CommandOutput, CommandSpec, ContainerTemplate,
1030        SshTarget,
1031    };
1032
1033    use super::*;
1034
1035    /// A fake executor that fails the SSH probe a fixed number of times.
1036    struct SshProbeExecutor {
1037        failures_remaining: RefCell<u32>,
1038        attempts: RefCell<u32>,
1039        cancel_after: Option<u32>,
1040    }
1041    impl SshProbeExecutor {
1042        fn new(failures: u32) -> Self {
1043            Self {
1044                failures_remaining: RefCell::new(failures),
1045                attempts: RefCell::new(0),
1046                cancel_after: None,
1047            }
1048        }
1049    }
1050    impl CommandExecutor for SshProbeExecutor {
1051        fn execute(&self, _command: &CommandSpec) -> Result<CommandOutput> {
1052            *self.attempts.borrow_mut() += 1;
1053            let mut remaining = self.failures_remaining.borrow_mut();
1054            if *remaining == 0 {
1055                return Ok(CommandOutput {
1056                    status: 0,
1057                    stdout: Vec::new(),
1058                    stderr: Vec::new(),
1059                });
1060            }
1061            *remaining -= 1;
1062            Ok(CommandOutput {
1063                status: 255,
1064                stdout: Vec::new(),
1065                stderr: b"ssh: connect to host 10.0.0.1 port 22: Connection refused\n".to_vec(),
1066            })
1067        }
1068
1069        fn cancellation_requested(&self) -> bool {
1070            self.cancel_after
1071                .is_some_and(|limit| *self.attempts.borrow() >= limit)
1072        }
1073    }
1074    fn ssh_probe_spec() -> CommandSpec {
1075        CommandSpec::new("ssh", ["host", "true"]).purpose("wait for EC2 SSH availability")
1076    }
1077    /// A virtual clock advanced only by the injected sleep hook.
1078    fn virtual_clock() -> (std::rc::Rc<std::cell::Cell<Instant>>, Instant) {
1079        let start = Instant::now();
1080        (std::rc::Rc::new(std::cell::Cell::new(start)), start)
1081    }
1082    #[test]
1083    fn ssh_readiness_wait_succeeds_after_failed_probes() {
1084        let executor = SshProbeExecutor::new(3);
1085        let (clock, _) = virtual_clock();
1086        let sleep_clock = clock.clone();
1087        wait_for_ssh_ready(
1088            &executor,
1089            &ssh_probe_spec(),
1090            Duration::from_secs(300),
1091            {
1092                let clock = clock.clone();
1093                move || clock.get()
1094            },
1095            move |delay| sleep_clock.set(sleep_clock.get() + delay),
1096        )
1097        .expect("the wait succeeds once SSH answers");
1098        assert_eq!(*executor.attempts.borrow(), 4);
1099    }
1100    #[test]
1101    fn ssh_readiness_wait_gives_up_at_the_deadline_and_reports_the_last_error() {
1102        let executor = SshProbeExecutor::new(u32::MAX);
1103        let (clock, _) = virtual_clock();
1104        let sleep_clock = clock.clone();
1105        let error = wait_for_ssh_ready(
1106            &executor,
1107            &ssh_probe_spec(),
1108            Duration::from_secs(30),
1109            {
1110                let clock = clock.clone();
1111                move || clock.get()
1112            },
1113            move |delay| sleep_clock.set(sleep_clock.get() + delay),
1114        )
1115        .expect_err("the wait stops at the deadline");
1116        let message = error.to_string();
1117        assert!(message.contains("timed out after 30s"), "{message}");
1118        assert!(message.contains("Connection refused"), "{message}");
1119    }
1120    #[test]
1121    fn ssh_readiness_wait_stops_when_cancellation_is_requested() {
1122        let mut executor = SshProbeExecutor::new(u32::MAX);
1123        executor.cancel_after = Some(2);
1124        let (clock, _) = virtual_clock();
1125        let sleep_clock = clock.clone();
1126        let error = wait_for_ssh_ready(
1127            &executor,
1128            &ssh_probe_spec(),
1129            Duration::from_secs(300),
1130            {
1131                let clock = clock.clone();
1132                move || clock.get()
1133            },
1134            move |delay| sleep_clock.set(sleep_clock.get() + delay),
1135        )
1136        .expect_err("the wait stops when cancelled");
1137        assert!(error.to_string().contains("cancelled"), "{error}");
1138        assert_eq!(*executor.attempts.borrow(), 2);
1139    }
1140    #[test]
1141    fn aws_resources_are_compressed_into_one_streamed_ssh_command() {
1142        struct RecordingExecutor {
1143            commands: RefCell<Vec<CommandSpec>>,
1144            streams: RefCell<Vec<Vec<u8>>>,
1145        }
1146        impl CommandExecutor for RecordingExecutor {
1147            fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
1148                self.commands.borrow_mut().push(command.clone());
1149                Ok(CommandOutput {
1150                    status: 0,
1151                    stdout: Vec::new(),
1152                    stderr: Vec::new(),
1153                })
1154            }
1155
1156            fn execute_with_stdin(
1157                &self,
1158                command: &CommandSpec,
1159                input: &mut (dyn std::io::Read + Send),
1160            ) -> Result<CommandOutput> {
1161                self.commands.borrow_mut().push(command.clone());
1162                let mut stream = Vec::new();
1163                input.read_to_end(&mut stream)?;
1164                self.streams.borrow_mut().push(stream);
1165                Ok(CommandOutput {
1166                    status: 0,
1167                    stdout: Vec::new(),
1168                    stderr: Vec::new(),
1169                })
1170            }
1171        }
1172
1173        let source = tempfile::tempdir().unwrap();
1174        std::fs::create_dir_all(source.path().join("many/files")).unwrap();
1175        std::fs::write(source.path().join("many/files/one"), b"one").unwrap();
1176        std::fs::write(source.path().join("many/files/two"), b"two").unwrap();
1177        let session_id = "0123456789abcdef0123456789abcdef";
1178        let record = SessionRecord {
1179            mjolnir_subagents: None,
1180            create_managed_worktree: None,
1181            workspace_id: mj_core::workspace::DEFAULT_WORKSPACE_ID.to_owned(),
1182            archived: false,
1183            container_cpus: None,
1184            container_memory: None,
1185            id: session_id.into(),
1186            title: "AWS resources".into(),
1187            harness_kind: mj_core::config::HarnessKind::Codex,
1188            last_profile: "codex".into(),
1189            bundle_id: "project".into(),
1190            project_directory: None,
1191            managed_worktree: None,
1192            target_template_id: "aws".into(),
1193            resource_allocation: None,
1194            additional_mounts: vec![AdditionalMount {
1195                source: source.path().to_path_buf(),
1196                destination: "/home/ubuntu/mj-resources/data".into(),
1197                read_only: false,
1198            }],
1199            state: SessionState::Disconnected,
1200            target: None,
1201            native_session_id: None,
1202            acp_session_title: None,
1203            session_title_override: None,
1204            created_at: "2026-08-12T00:00:00Z".into(),
1205            updated_at: "2026-08-12T00:00:00Z".into(),
1206            viewed_through_event_ordinal: 0,
1207            draft_input: String::new(),
1208            last_error: None,
1209            last_checkpoint_error: None,
1210            checkpoint: None,
1211        };
1212        let state = State {
1213            subagents: Default::default(),
1214            version: mj_core::state::STATE_VERSION,
1215            sessions: BTreeMap::from([(session_id.into(), record)]),
1216            mount_history: BTreeMap::new(),
1217            container_sizes: BTreeMap::new(),
1218        };
1219        let backend = targets::TargetLocator::AwsEc2 {
1220            profile: "default".into(),
1221            region: "us-east-1".into(),
1222            instance_id: "i-1234567890abcdef0".into(),
1223            ssh: SshTarget {
1224                destination: "ubuntu@example.test".into(),
1225                ssh_args: Vec::new(),
1226            },
1227            workspace: format!(".local/share/hel/workspaces/{session_id}"),
1228        };
1229        let executor = RecordingExecutor {
1230            commands: RefCell::new(Vec::new()),
1231            streams: RefCell::new(Vec::new()),
1232        };
1233
1234        install_attached_resources(
1235            &state,
1236            session_id,
1237            &backend,
1238            ".local/share/hel/workers/session",
1239            &executor,
1240        )
1241        .unwrap();
1242
1243        let commands = executor.commands.borrow();
1244        assert_eq!(commands.len(), 1);
1245        assert_eq!(commands[0].program, "ssh");
1246        assert!(
1247            commands[0]
1248                .args
1249                .iter()
1250                .any(|argument| argument.contains("install-resource"))
1251        );
1252        let streams = executor.streams.borrow();
1253        assert_eq!(streams.len(), 1);
1254        assert_eq!(&streams[0][..2], &[0x1f, 0x8b]);
1255    }
1256    #[test]
1257    fn canonical_bundle_maps_github_shorthand_and_primary_destination() {
1258        let bundle = ProjectBundle {
1259            primary_repo: "app".into(),
1260            repositories: vec![ProjectRepository {
1261                id: "app".into(),
1262                github: Some("example/app".into()),
1263                local: None,
1264                destination: PathBuf::from("services/app"),
1265                git_ref: None,
1266            }],
1267        };
1268        let backend = backend_bundle(&bundle, &targets::ProcessExecutor).unwrap();
1269        assert_eq!(backend.primary, "services/app");
1270        assert_eq!(
1271            backend.repositories[0].url.as_deref(),
1272            Some("https://github.com/example/app.git")
1273        );
1274    }
1275    #[test]
1276    fn container_resources_and_environment_become_argv() {
1277        let template = TargetTemplate::LocalPodman {
1278            container: ConfigContainer {
1279                image: "dev:1".into(),
1280                pull_policy: mj_core::config::ImagePullPolicy::Never,
1281                platform: Some("linux/arm64".into()),
1282                cpus: Some("4".into()),
1283                memory: Some("8g".into()),
1284                environment: std::collections::BTreeMap::from([("A".into(), "b c".into())]),
1285                workspace_storage: Default::default(),
1286            },
1287        };
1288        let targets::TargetTemplate::LocalPodman(container) =
1289            backend_target(&template, None, ContainerOverrides::default()).unwrap()
1290        else {
1291            unreachable!()
1292        };
1293        assert!(container.extra_run_args.contains(&"--cpus=4".into()));
1294        assert!(container.extra_run_args.contains(&"A=b c".into()));
1295        assert_eq!(
1296            container.pull_policy,
1297            mj_core::config::ImagePullPolicy::Never
1298        );
1299    }
1300    #[test]
1301    fn session_size_overrides_beat_the_target_template_and_its_allocation() {
1302        let template = TargetTemplate::LocalPodman {
1303            container: ConfigContainer {
1304                image: "dev:1".into(),
1305                pull_policy: Default::default(),
1306                platform: None,
1307                cpus: Some("4".into()),
1308                memory: Some("8g".into()),
1309                environment: std::collections::BTreeMap::new(),
1310                workspace_storage: Default::default(),
1311            },
1312        };
1313        let mut session = crate::controller::test_support::checkpoint_test_session("session-size");
1314        session.container_cpus = Some("2".into());
1315        session.container_memory = Some("3g".into());
1316        session.resource_allocation = Some(SessionResourceAllocation::Container {
1317            cpus: 16,
1318            memory_bytes: 64_000_000_000,
1319        });
1320        let targets::TargetTemplate::LocalPodman(container) = backend_target(
1321            &template,
1322            session.resource_allocation.as_ref(),
1323            ContainerOverrides::for_session(&session),
1324        )
1325        .unwrap() else {
1326            unreachable!()
1327        };
1328        assert!(container.extra_run_args.contains(&"--cpus=2".into()));
1329        assert!(container.extra_run_args.contains(&"--memory=3g".into()));
1330        assert!(!container.extra_run_args.iter().any(|argument| {
1331            argument.starts_with("--cpus=4")
1332                || argument.starts_with("--cpus=16")
1333                || argument.starts_with("--memory=8g")
1334        }));
1335    }
1336    #[test]
1337    fn github_token_is_inherited_only_by_managed_containers() {
1338        let mut podman = targets::TargetTemplate::LocalPodman(ContainerTemplate {
1339            image: "dev:1".into(),
1340            pull_policy: Default::default(),
1341            extra_run_args: vec![],
1342            workspace_storage: Default::default(),
1343        });
1344        assert!(configure_github_token_environment(&mut podman));
1345        let targets::TargetTemplate::LocalPodman(container) = podman else {
1346            unreachable!()
1347        };
1348        assert!(
1349            container
1350                .extra_run_args
1351                .windows(2)
1352                .any(|arguments| arguments == ["--env", "GH_TOKEN"])
1353        );
1354        assert!(
1355            !container
1356                .extra_run_args
1357                .iter()
1358                .any(|argument| argument.contains("github-token"))
1359        );
1360
1361        let mut bare = targets::TargetTemplate::LocalBare;
1362        assert!(!configure_github_token_environment(&mut bare));
1363        assert_eq!(bare, targets::TargetTemplate::LocalBare);
1364        assert_eq!(usable_github_token("  token-value\n"), Some("token-value"));
1365        assert_eq!(usable_github_token("not a token"), None);
1366
1367        let mut bundle = targets::ProjectBundleSpec {
1368            primary: "app".into(),
1369            repositories: vec![targets::RepositorySpec {
1370                url: Some("git@github.com:example/app.git".into()),
1371                push_urls: vec![
1372                    "git@github.com:fork/app.git".into(),
1373                    "ssh://git@example.test/app.git".into(),
1374                ],
1375                destination: "app".into(),
1376                git_ref: None,
1377                reference: None,
1378            }],
1379        };
1380        use_github_https_urls(&mut bundle);
1381        assert_eq!(
1382            bundle.repositories[0].url.as_deref(),
1383            Some("https://github.com/example/app.git")
1384        );
1385        assert_eq!(
1386            bundle.repositories[0].push_urls,
1387            [
1388                "https://github.com/fork/app.git",
1389                "ssh://git@example.test/app.git"
1390            ]
1391        );
1392    }
1393    fn container_target(
1394        image: &str,
1395        pull_policy: mj_core::config::ImagePullPolicy,
1396    ) -> ConfigContainer {
1397        ConfigContainer {
1398            image: image.into(),
1399            pull_policy,
1400            platform: None,
1401            cpus: None,
1402            memory: None,
1403            environment: BTreeMap::new(),
1404            workspace_storage: Default::default(),
1405        }
1406    }
1407
1408    #[test]
1409    fn the_image_refresh_plan_covers_every_image_a_launch_no_longer_pulls() {
1410        use mj_core::config::ImagePullPolicy;
1411
1412        let mut config = Config::default();
1413        config.targets.insert(
1414            "podman".into(),
1415            TargetTemplate::LocalPodman {
1416                container: container_target("ghcr.io/example/dev:latest", ImagePullPolicy::Auto),
1417            },
1418        );
1419        // The same image on the same host, named by a second target.
1420        config.targets.insert(
1421            "podman-again".into(),
1422            TargetTemplate::LocalPodman {
1423                container: container_target("ghcr.io/example/dev:latest", ImagePullPolicy::Auto),
1424            },
1425        );
1426        config.targets.insert(
1427            "ssh".into(),
1428            TargetTemplate::SshPodman {
1429                ssh: SshConnection {
1430                    host: "builder.example.test".into(),
1431                    user: Some("dev".into()),
1432                    identity_file: Some(PathBuf::from("/home/dev/.ssh/builder")),
1433                    extra_args: Vec::new(),
1434                },
1435                container: ConfigContainer {
1436                    platform: Some("linux/amd64".into()),
1437                    ..container_target("ghcr.io/example/dev:latest", ImagePullPolicy::Auto)
1438                },
1439            },
1440        );
1441        config.targets.insert(
1442            "docker".into(),
1443            TargetTemplate::LocalDocker {
1444                container: container_target("ghcr.io/example/dev:1.2.3", ImagePullPolicy::Newer),
1445            },
1446        );
1447        config.targets.insert(
1448            "pinned".into(),
1449            TargetTemplate::LocalPodman {
1450                container: container_target(
1451                    "ghcr.io/example/dev@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
1452                    ImagePullPolicy::Auto,
1453                ),
1454            },
1455        );
1456        // Apple's engine still refreshes its image during provisioning.
1457        config.targets.insert(
1458            "apple".into(),
1459            TargetTemplate::AppleContainer {
1460                container: container_target("ghcr.io/example/dev:latest", ImagePullPolicy::Auto),
1461            },
1462        );
1463
1464        let plan = image_refresh_plan(&config);
1465        assert_eq!(
1466            plan.len(),
1467            3,
1468            "expected one refresh per host and image: {plan:?}"
1469        );
1470
1471        let local = plan
1472            .iter()
1473            .find(|refresh| refresh.host == ImageHost::LocalPodman)
1474            .expect("the auto latest target is refreshed");
1475        assert_eq!(local.pull.program, "podman");
1476        assert_eq!(local.pull.args, ["pull", "ghcr.io/example/dev:latest"]);
1477        assert_eq!(local.prune.args, ["image", "prune", "-f"]);
1478        assert_eq!(
1479            local.image_id.args,
1480            [
1481                "image",
1482                "inspect",
1483                "--format",
1484                "{{.Id}}",
1485                "ghcr.io/example/dev:latest"
1486            ]
1487        );
1488
1489        let docker = plan
1490            .iter()
1491            .find(|refresh| refresh.host == ImageHost::LocalDocker)
1492            .expect("the explicit newer policy is refreshed too");
1493        assert_eq!(docker.pull.program, "docker");
1494        assert_eq!(docker.pull.args, ["pull", "ghcr.io/example/dev:1.2.3"]);
1495        assert_eq!(docker.prune.args, ["image", "prune", "-f"]);
1496
1497        let ssh = plan
1498            .iter()
1499            .find(|refresh| matches!(refresh.host, ImageHost::SshPodman(_)))
1500            .expect("the SSH host is refreshed over its own connection");
1501        assert_eq!(ssh.pull.program, "ssh");
1502        // The identity file and destination come from the same builder
1503        // provisioning uses.
1504        assert!(ssh.pull.args.contains(&"/home/dev/.ssh/builder".to_owned()));
1505        assert!(
1506            ssh.pull
1507                .args
1508                .contains(&"dev@builder.example.test".to_owned())
1509        );
1510        assert_eq!(
1511            ssh.pull.args.last().map(String::as_str),
1512            Some("'podman' 'pull' '--platform=linux/amd64' 'ghcr.io/example/dev:latest'")
1513        );
1514        assert_eq!(
1515            ssh.prune.args.last().map(String::as_str),
1516            Some("'podman' 'image' 'prune' '-f'")
1517        );
1518
1519        assert!(
1520            !plan.iter().any(|refresh| refresh.image.contains("sha256:")),
1521            "a digest-pinned image was refreshed: {plan:?}"
1522        );
1523    }
1524
1525    #[test]
1526    fn ssh_docker_image_refresh_runs_docker_on_the_configured_host() {
1527        use mj_core::config::ImagePullPolicy;
1528
1529        let mut config = Config::default();
1530        config.targets.insert(
1531            "docker".into(),
1532            TargetTemplate::SshDocker {
1533                ssh: SshConnection {
1534                    host: "builder.example.test".into(),
1535                    user: Some("dev".into()),
1536                    identity_file: None,
1537                    extra_args: Vec::new(),
1538                },
1539                container: ConfigContainer {
1540                    image: "ghcr.io/example/dev:latest".into(),
1541                    pull_policy: ImagePullPolicy::Auto,
1542                    platform: Some("linux/amd64".into()),
1543                    cpus: None,
1544                    memory: None,
1545                    environment: BTreeMap::new(),
1546                    workspace_storage: Default::default(),
1547                },
1548            },
1549        );
1550
1551        let refresh = image_refresh_plan(&config).pop().expect("refresh plan");
1552        assert_eq!(
1553            refresh.host,
1554            ImageHost::SshDocker(backend_ssh(match config.targets.get("docker").unwrap() {
1555                TargetTemplate::SshDocker { ssh, .. } => ssh,
1556                _ => unreachable!(),
1557            }))
1558        );
1559        assert_eq!(refresh.pull.program, "ssh");
1560        assert_eq!(
1561            refresh.pull.args.last().map(String::as_str),
1562            Some("'docker' 'pull' '--platform=linux/amd64' 'ghcr.io/example/dev:latest'")
1563        );
1564        assert_eq!(
1565            refresh.prune.args.last().map(String::as_str),
1566            Some("'docker' 'image' 'prune' '-f'")
1567        );
1568    }
1569
1570    #[test]
1571    fn aws_resource_options_follow_the_launch_template_family() {
1572        let mut config = Config::default();
1573        config.targets.insert(
1574            "aws".into(),
1575            TargetTemplate::AwsEc2 {
1576                aws_profile: None,
1577                region: "us-east-1".into(),
1578                launch_template: "hel-runson".into(),
1579                launch_template_version: None,
1580                ssh_user: "ubuntu".into(),
1581                address_source: AwsAddressSource::PublicIp,
1582                identity_file: None,
1583                ssh_args: Vec::new(),
1584            },
1585        );
1586        let executor = PreflightExecutor {
1587            outputs: RefCell::new(vec![
1588                CommandOutput {
1589                    status: 0,
1590                    stdout: br#"{"LaunchTemplateVersions":[{"LaunchTemplateData":{"InstanceType":"m8i-flex.large"}}]}"#.to_vec(),
1591                    stderr: Vec::new(),
1592                },
1593                CommandOutput {
1594                    status: 0,
1595                    stdout: br#"{"InstanceTypes":[{"InstanceType":"m8i-flex.4xlarge","VCpuInfo":{"DefaultVCpus":16},"MemoryInfo":{"SizeInMiB":65536}},{"InstanceType":"m8i-flex.2xlarge","VCpuInfo":{"DefaultVCpus":8},"MemoryInfo":{"SizeInMiB":32768}}]}"#.to_vec(),
1596                    stderr: Vec::new(),
1597                },
1598            ]),
1599            notices: RefCell::new(vec![]),
1600        };
1601        let controller = Controller {
1602            config,
1603            state: State::default(),
1604        };
1605
1606        let options = controller
1607            .resolve_aws_resource_options("aws", &executor)
1608            .unwrap();
1609        assert_eq!(
1610            options.iter().map(allocation_cpus).collect::<Vec<_>>(),
1611            [8, 16]
1612        );
1613    }
1614    #[test]
1615    fn deployment_capacity_groups_local_and_same_host_targets() {
1616        let container = || ConfigContainer {
1617            image: "dev:1".into(),
1618            pull_policy: Default::default(),
1619            platform: None,
1620            cpus: None,
1621            memory: None,
1622            environment: BTreeMap::new(),
1623            workspace_storage: Default::default(),
1624        };
1625        let ssh = |host: &str| SshConnection {
1626            host: host.into(),
1627            user: Some("builder".into()),
1628            identity_file: None,
1629            extra_args: Vec::new(),
1630        };
1631        let config = Config {
1632            subagents: Default::default(),
1633            version: mj_core::config::CONFIG_VERSION,
1634            sessions_side: Default::default(),
1635            advanced: Default::default(),
1636            show_stopped_sessions: false,
1637            newer_config_version: None,
1638            spinner: Default::default(),
1639            theme: Default::default(),
1640            phone: Default::default(),
1641            review: Default::default(),
1642            legacy_startup: (),
1643            profiles: BTreeMap::new(),
1644            bundles: BTreeMap::new(),
1645            targets: BTreeMap::from([
1646                (
1647                    "apple".into(),
1648                    TargetTemplate::AppleContainer {
1649                        container: container(),
1650                    },
1651                ),
1652                (
1653                    "local".into(),
1654                    TargetTemplate::LocalPodman {
1655                        container: container(),
1656                    },
1657                ),
1658                (
1659                    "bare".into(),
1660                    TargetTemplate::SshBare {
1661                        ssh: ssh("builder"),
1662                        permissions: mj_core::config::PermissionMode::Yolo,
1663                        workspace_prefix: ".local/share/hel/workspaces".into(),
1664                    },
1665                ),
1666                (
1667                    "remote-container".into(),
1668                    TargetTemplate::SshPodman {
1669                        ssh: ssh("builder"),
1670                        container: container(),
1671                    },
1672                ),
1673                (
1674                    "alias".into(),
1675                    TargetTemplate::SshBare {
1676                        ssh: ssh("builder-alias"),
1677                        permissions: mj_core::config::PermissionMode::Yolo,
1678                        workspace_prefix: ".local/share/hel/workspaces".into(),
1679                    },
1680                ),
1681            ]),
1682        };
1683        let controller = Controller {
1684            config,
1685            state: State::default(),
1686        };
1687
1688        let targets = controller.deployment_capacity_targets();
1689
1690        assert_eq!(targets.len(), 3);
1691        let local = targets.iter().find(|target| target.id == "local").unwrap();
1692        assert_eq!(local.target_ids, ["apple", "local"]);
1693        let builder = targets
1694            .iter()
1695            .find(|target| target.id == "ssh:builder")
1696            .unwrap();
1697        assert_eq!(builder.target_ids, ["bare", "remote-container"]);
1698        assert_eq!(builder.probes.len(), 1);
1699        assert!(
1700            targets
1701                .iter()
1702                .any(|target| target.id == "ssh:builder-alias")
1703        );
1704    }
1705    struct PreflightExecutor {
1706        outputs: RefCell<Vec<CommandOutput>>,
1707        notices: RefCell<Vec<String>>,
1708    }
1709    impl CommandExecutor for PreflightExecutor {
1710        fn execute(&self, _command: &CommandSpec) -> Result<CommandOutput> {
1711            Ok(self.outputs.borrow_mut().remove(0))
1712        }
1713
1714        fn notify_notice(&self, notice: &str) {
1715            self.notices.borrow_mut().push(notice.to_owned());
1716        }
1717    }
1718    #[test]
1719    fn local_podman_preflight_failures_recommend_doctor() {
1720        let template = TargetTemplate::LocalPodman {
1721            container: ConfigContainer {
1722                image: "ubuntu:24.04".into(),
1723                pull_policy: Default::default(),
1724                platform: None,
1725                cpus: None,
1726                memory: None,
1727                environment: std::collections::BTreeMap::new(),
1728                workspace_storage: Default::default(),
1729            },
1730        };
1731        let executor = PreflightExecutor {
1732            outputs: RefCell::new(vec![CommandOutput {
1733                status: 0,
1734                stdout: b"podman version 3.4.7\n".to_vec(),
1735                stderr: vec![],
1736            }]),
1737            notices: RefCell::new(vec![]),
1738        };
1739
1740        let error = preflight_target(&template, &executor)
1741            .unwrap_err()
1742            .to_string();
1743        assert!(error.contains("mj doctor"));
1744        assert!(error.contains("Podman 4.0.0"));
1745    }
1746    #[test]
1747    fn ssh_podman_preflight_failures_name_the_destination_and_recommend_doctor() {
1748        let template = TargetTemplate::SshPodman {
1749            ssh: SshConnection {
1750                host: "example.test".into(),
1751                user: Some("dev".into()),
1752                identity_file: None,
1753                extra_args: vec![],
1754            },
1755            container: ConfigContainer {
1756                image: "ubuntu:24.04".into(),
1757                pull_policy: Default::default(),
1758                platform: None,
1759                cpus: None,
1760                memory: None,
1761                environment: std::collections::BTreeMap::new(),
1762                workspace_storage: Default::default(),
1763            },
1764        };
1765        let executor = PreflightExecutor {
1766            outputs: RefCell::new(vec![CommandOutput {
1767                status: 0,
1768                stdout: b"podman version 3.4.7\n".to_vec(),
1769                stderr: vec![],
1770            }]),
1771            notices: RefCell::new(vec![]),
1772        };
1773
1774        let error = preflight_target(&template, &executor)
1775            .unwrap_err()
1776            .to_string();
1777        assert!(error.contains("mj doctor"));
1778        assert!(error.contains("dev@example.test"));
1779        assert!(error.contains("Podman 4.0.0"));
1780    }
1781    #[test]
1782    fn ssh_podman_preflight_notifies_when_remote_user_lingering_is_disabled() {
1783        let template = TargetTemplate::SshPodman {
1784            ssh: SshConnection {
1785                host: "example.test".into(),
1786                user: Some("dev".into()),
1787                identity_file: None,
1788                extra_args: vec![],
1789            },
1790            container: ConfigContainer {
1791                image: "ubuntu:24.04".into(),
1792                pull_policy: Default::default(),
1793                platform: None,
1794                cpus: None,
1795                memory: None,
1796                environment: std::collections::BTreeMap::new(),
1797                workspace_storage: Default::default(),
1798            },
1799        };
1800        let executor = PreflightExecutor {
1801            outputs: RefCell::new(vec![
1802                CommandOutput {
1803                    status: 0,
1804                    stdout: b"podman version 5.4.2\n".to_vec(),
1805                    stderr: vec![],
1806                },
1807                CommandOutput {
1808                    status: 0,
1809                    stdout: b"true\n".to_vec(),
1810                    stderr: vec![],
1811                },
1812                CommandOutput {
1813                    status: 0,
1814                    stdout: b"0 1000 1\n1 100000 65536\n".to_vec(),
1815                    stderr: vec![],
1816                },
1817                CommandOutput {
1818                    status: 0,
1819                    stdout: b"no\n".to_vec(),
1820                    stderr: vec![],
1821                },
1822            ]),
1823            notices: RefCell::new(vec![]),
1824        };
1825
1826        preflight_target(&template, &executor).unwrap();
1827
1828        let notices = executor.notices.borrow();
1829        assert_eq!(notices.len(), 1);
1830        assert!(notices[0].contains("last SSH connection closes"));
1831        assert!(notices[0].contains("sudo loginctl enable-linger"));
1832    }
1833    #[test]
1834    fn apple_container_preflight_failures_recommend_doctor() {
1835        let template = TargetTemplate::AppleContainer {
1836            container: ConfigContainer {
1837                image: "ubuntu:24.04".into(),
1838                pull_policy: Default::default(),
1839                platform: None,
1840                cpus: None,
1841                memory: None,
1842                environment: std::collections::BTreeMap::new(),
1843                workspace_storage: Default::default(),
1844            },
1845        };
1846        let executor = PreflightExecutor {
1847            outputs: RefCell::new(vec![CommandOutput {
1848                status: 1,
1849                stdout: vec![],
1850                stderr: b"daemon is not running".to_vec(),
1851            }]),
1852            notices: RefCell::new(vec![]),
1853        };
1854
1855        let error = preflight_target(&template, &executor)
1856            .unwrap_err()
1857            .to_string();
1858        assert!(error.contains("mj doctor"));
1859        assert!(error.contains("daemon is not running"));
1860    }
1861}