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