Skip to main content

mj_controller/controller/
backend.rs

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