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