Skip to main content

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