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::{AwsAddressSource, Config, ProjectBundle, TargetTemplate, data_dir};
11use mj_core::state::{
12    PodmanWorkspaceLocator, SessionRecord, SessionResourceAllocation, TargetLocator,
13    allocation_cpus,
14};
15
16use crate::targets::{
17    self, AwsTemplate, CommandExecutor, CommandOutput, CommandSpec, ContainerTemplate, ImageHost,
18    ImageRefresh, ProjectBundleSpec, ProvisionStage, RepositorySpec, SshTarget,
19};
20
21use super::{Controller, execute_checked};
22
23impl Controller {
24    /// Inspect the actual execution checkout in a background worker.
25    pub fn session_working_context(
26        &self,
27        session_id: &str,
28        executor: &impl CommandExecutor,
29    ) -> Result<(PathBuf, String)> {
30        let session = self
31            .state
32            .sessions
33            .get(session_id)
34            .context("session is missing")?;
35        let locator = session
36            .target
37            .as_ref()
38            .context("target is still starting")?;
39        let backend = backend_locator(locator, session, &self.config)?;
40        let launch = self.current_worker_launch_config(session_id, &backend)?;
41        let output = executor.execute(&targets::command_on_locator(
42            &backend,
43            session_id,
44            vec![
45                "git".into(),
46                "-C".into(),
47                launch.cwd.to_string_lossy().into_owned(),
48                "rev-parse".into(),
49                "--abbrev-ref".into(),
50                "HEAD".into(),
51            ],
52            "read current session branch",
53        )?)?;
54        let branch = if output.status == 0 {
55            let branch = String::from_utf8(output.stdout).context("decode session branch")?;
56            if branch.trim() == "HEAD" {
57                "detached HEAD".to_owned()
58            } else {
59                branch.trim().to_owned()
60            }
61        } else {
62            let stderr = String::from_utf8_lossy(&output.stderr);
63            if stderr.contains("not a git repository") {
64                // A plain folder opened through `mj go` has no branch. Say so
65                // briefly rather than echoing multi-line git stderr, which wraps
66                // the banner.
67                "not a git checkout".to_owned()
68            } else {
69                format!(
70                    "unavailable: {}",
71                    stderr.lines().next().unwrap_or("").trim()
72                )
73            }
74        };
75        Ok((launch.cwd, branch))
76    }
77
78    pub fn resolve_aws_resource_options(
79        &self,
80        target_id: &str,
81        executor: &impl CommandExecutor,
82    ) -> Result<Vec<SessionResourceAllocation>> {
83        let TargetTemplate::AwsEc2 {
84            aws_profile,
85            region,
86            launch_template,
87            launch_template_version,
88            ..
89        } = self
90            .config
91            .targets
92            .get(target_id)
93            .with_context(|| format!("unknown target template {target_id:?}"))?
94        else {
95            bail!("target {target_id:?} is not an AWS EC2 target");
96        };
97        let profile = aws_profile.as_deref().unwrap_or("default");
98        let launch_key = if launch_template.starts_with("lt-") {
99            "--launch-template-id"
100        } else {
101            "--launch-template-name"
102        };
103        let version = launch_template_version.as_deref().unwrap_or("$Default");
104        let describe_template = CommandSpec::new(
105            "aws",
106            [
107                "--profile",
108                profile,
109                "--region",
110                region,
111                "ec2",
112                "describe-launch-template-versions",
113                launch_key,
114                launch_template,
115                "--versions",
116                version,
117                "--output",
118                "json",
119            ],
120        )
121        .purpose("resolve EC2 launch template instance family");
122        let output = executor.execute(&describe_template)?;
123        if output.status != 0 {
124            bail!(
125                "{} failed with status {}: {}",
126                describe_template.purpose,
127                output.status,
128                String::from_utf8_lossy(&output.stderr).trim()
129            );
130        }
131        let response: serde_json::Value =
132            serde_json::from_slice(&output.stdout).context("parse EC2 launch template response")?;
133        let instance_type = response
134            .pointer("/LaunchTemplateVersions/0/LaunchTemplateData/InstanceType")
135            .and_then(serde_json::Value::as_str)
136            .context("launch template does not specify a concrete instance type")?;
137        let family = instance_type
138            .rsplit_once('.')
139            .map(|(family, _)| family)
140            .context("launch template instance type has no size suffix")?;
141        let filter = format!("Name=instance-type,Values={family}.*");
142        let describe_types = CommandSpec::new(
143            "aws",
144            [
145                "--profile",
146                profile,
147                "--region",
148                region,
149                "ec2",
150                "describe-instance-types",
151                "--filters",
152                &filter,
153                "--output",
154                "json",
155            ],
156        )
157        .purpose("discover EC2 instance sizes");
158        let output = executor.execute(&describe_types)?;
159        if output.status != 0 {
160            bail!(
161                "{} failed with status {}: {}",
162                describe_types.purpose,
163                output.status,
164                String::from_utf8_lossy(&output.stderr).trim()
165            );
166        }
167        let response: serde_json::Value =
168            serde_json::from_slice(&output.stdout).context("parse EC2 instance type response")?;
169        let mut options = response
170            .get("InstanceTypes")
171            .and_then(serde_json::Value::as_array)
172            .context("EC2 instance type response omitted InstanceTypes")?
173            .iter()
174            .filter_map(|entry| {
175                Some(SessionResourceAllocation::AwsEc2 {
176                    instance_type: entry.get("InstanceType")?.as_str()?.to_owned(),
177                    vcpus: entry.pointer("/VCpuInfo/DefaultVCpus")?.as_u64()?,
178                    memory_bytes: entry
179                        .pointer("/MemoryInfo/SizeInMiB")?
180                        .as_u64()?
181                        .checked_mul(1024 * 1024)?,
182                })
183            })
184            .collect::<Vec<_>>();
185        options.sort_by_key(allocation_cpus);
186        if !options.iter().any(|option| allocation_cpus(option) == 8) {
187            bail!("EC2 family {family:?} has no exact 8-vCPU baseline size");
188        }
189        Ok(options)
190    }
191
192    pub fn reconnect_command(&self, session_id: &str) -> Result<CommandSpec> {
193        let session = self
194            .state
195            .sessions
196            .get(session_id)
197            .with_context(|| format!("unknown session {session_id}"))?;
198        session.validate_configuration(&self.config)?;
199        let locator = session.target.as_ref().context("session has no target")?;
200        let backend = backend_locator(locator, session, &self.config)?;
201        targets::reconnect_plan(&backend, session_id)?
202            .commands
203            .into_iter()
204            .next()
205            .context("reconnect plan is empty")
206    }
207
208    pub fn resource_probe(&self, session_id: &str) -> Result<targets::SessionResourceProbe> {
209        let session = self
210            .state
211            .sessions
212            .get(session_id)
213            .with_context(|| format!("unknown session {session_id}"))?;
214        let locator = session.target.as_ref().context("session has no target")?;
215        let backend = backend_locator(locator, session, &self.config)?;
216        targets::resource_probe(&backend, session_id)
217    }
218
219    pub fn deployment_capacity_targets(&self) -> Vec<targets::DeploymentCapacityTarget> {
220        use targets::{DeploymentCapacityKind, DeploymentCapacityTarget};
221
222        let mut local_ids = Vec::new();
223        let mut ssh_hosts: BTreeMap<String, (Vec<String>, Vec<CommandSpec>)> = BTreeMap::new();
224        let mut targets = Vec::new();
225        for (target_id, template) in &self.config.targets {
226            match template {
227                TargetTemplate::LocalBare
228                | TargetTemplate::LocalPodman { .. }
229                | TargetTemplate::LocalDocker { .. }
230                | TargetTemplate::AppleContainer { .. } => {
231                    local_ids.push(target_id.clone());
232                }
233                TargetTemplate::SshBare { ssh, .. }
234                | TargetTemplate::SshPodman { ssh, .. }
235                | TargetTemplate::SshDocker { ssh, .. } => {
236                    let entry = ssh_hosts.entry(ssh.host.clone()).or_default();
237                    entry.0.push(target_id.clone());
238                    let command = targets::ssh_host_capacity_command(&SshTarget::from(ssh));
239                    if !entry.1.contains(&command) {
240                        entry.1.push(command);
241                    }
242                }
243                TargetTemplate::AwsEc2 { .. } => {
244                    let mut probes = Vec::new();
245                    let mut probe_error = None;
246                    for session in self.state.sessions.values().filter(|session| {
247                        session.target_template_id == *target_id
248                            && session.state.is_active()
249                            && session.target.is_some()
250                    }) {
251                        let result = backend_locator(
252                            session.target.as_ref().expect("filtered target"),
253                            session,
254                            &self.config,
255                        )
256                        .and_then(|locator| {
257                            targets::aws_allocated_capacity_command(&locator, &session.id)
258                        });
259                        match result {
260                            Ok(command) => probes.push(command),
261                            Err(error) => probe_error = Some(format!("{error:#}")),
262                        }
263                    }
264                    targets.push(DeploymentCapacityTarget {
265                        id: format!("aws:{target_id}"),
266                        host: target_id.clone(),
267                        target_ids: vec![target_id.clone()],
268                        kind: DeploymentCapacityKind::AwsFleet,
269                        local: false,
270                        probes,
271                        probe_error,
272                    });
273                }
274            }
275        }
276        if !local_ids.is_empty() {
277            targets.push(DeploymentCapacityTarget {
278                id: "local".into(),
279                host: "local".into(),
280                target_ids: local_ids,
281                kind: DeploymentCapacityKind::Host,
282                local: true,
283                probes: Vec::new(),
284                probe_error: None,
285            });
286        }
287        targets.extend(ssh_hosts.into_iter().map(|(host, (target_ids, probes))| {
288            DeploymentCapacityTarget {
289                id: format!("ssh:{host}"),
290                host,
291                target_ids,
292                kind: DeploymentCapacityKind::Host,
293                local: false,
294                probes,
295                probe_error: None,
296            }
297        }));
298        targets.sort_by(|left, right| left.id.cmp(&right.id));
299        targets
300    }
301
302    pub fn test_target(&self, target_id: &str, executor: &impl CommandExecutor) -> Result<()> {
303        let template = self
304            .config
305            .targets
306            .get(target_id)
307            .with_context(|| format!("unknown target template {target_id:?}"))?;
308        preflight_target(template, executor)
309    }
310}
311
312pub(super) fn preflight_target(
313    template: &TargetTemplate,
314    executor: &impl CommandExecutor,
315) -> Result<()> {
316    match template {
317        TargetTemplate::LocalPodman { .. } => targets::verify_local_podman(executor)
318            .map(|_| ())
319            .map_err(|error| {
320                anyhow::anyhow!(
321                    "local Podman is not ready. Fix the problem below, then Retry launch: {error:#}"
322                )
323            }),
324        TargetTemplate::LocalDocker { .. } => targets::verify_local_docker(executor)
325            .map(|_| ())
326            .map_err(|error| {
327                anyhow::anyhow!(
328                    "local Docker is not ready. Start Docker or fix the problem below, then Retry launch: {error:#}"
329                )
330            }),
331        TargetTemplate::SshPodman { ssh, .. } => {
332            let ssh = SshTarget::from(ssh);
333            targets::verify_ssh_podman(&ssh, executor)
334                .map(|preflight| {
335                    for warning in preflight.warnings {
336                        executor.notify_notice(&warning.notice());
337                    }
338                })
339                .map_err(|error| {
340                    anyhow::anyhow!(
341                        "remote Podman is not ready on {}. Fix the problem below, then Retry launch: {error:#}",
342                        ssh.destination
343                    )
344                })
345        }
346        TargetTemplate::SshDocker { ssh, .. } => {
347            let ssh = SshTarget::from(ssh);
348            targets::verify_ssh_docker(&ssh, executor)
349                .map(|_| ())
350                .map_err(|error| {
351                    anyhow::anyhow!(
352                        "remote Docker preflight failed for {}. Fix the problem below, then Retry launch: {error:#}",
353                        ssh.destination
354                    )
355                })
356        }
357        TargetTemplate::AppleContainer { .. } => {
358            let command = CommandSpec::new("container", ["system", "status"])
359                .purpose("preflight Apple container runtime")
360                .stage(ProvisionStage::Provisioning);
361            let output = executor.execute(&command).map_err(|error| {
362                anyhow::anyhow!(
363                    "Apple container is not ready. Fix the problem below, then Retry launch: {error}"
364                )
365            })?;
366            if output.status != 0 {
367                bail!(
368                    "Apple container is not ready. Start the runtime with `container system start`, then Retry launch: container system status exited {}: {}",
369                    output.status,
370                    [
371                        String::from_utf8_lossy(&output.stdout).trim(),
372                        String::from_utf8_lossy(&output.stderr).trim(),
373                    ]
374                    .into_iter()
375                    .filter(|message| !message.is_empty())
376                    .collect::<Vec<_>>()
377                    .join("\n")
378                );
379            }
380            Ok(())
381        }
382        TargetTemplate::SshBare { ssh, .. } => {
383            let ssh = SshTarget::from(ssh);
384            let command = targets::ssh_connectivity_probe(&ssh);
385            let output = executor.execute(&command)?;
386            ensure!(
387                output.status == 0,
388                "SSH connectivity test failed for {} with status {}: {}",
389                ssh.destination,
390                output.status,
391                String::from_utf8_lossy(&output.stderr).trim()
392            );
393            Ok(())
394        }
395        TargetTemplate::AwsEc2 {
396            aws_profile,
397            region,
398            launch_template,
399            launch_template_version,
400            ..
401        } => {
402            let mut identity_args = vec!["sts".into(), "get-caller-identity".into()];
403            if let Some(profile) = aws_profile {
404                identity_args.extend(["--profile".into(), profile.clone()]);
405            }
406            let identity = CommandSpec::new("aws", identity_args)
407                .purpose("verify AWS credentials")
408                .stage(ProvisionStage::Provisioning);
409            let output = executor.execute(&identity)?;
410            ensure!(
411                output.status == 0,
412                "AWS credential test failed with status {}: {}",
413                output.status,
414                String::from_utf8_lossy(&output.stderr).trim()
415            );
416
417            let mut launch_args = vec![
418                "ec2".into(),
419                "describe-launch-template-versions".into(),
420                "--region".into(),
421                region.clone(),
422                "--launch-template-name".into(),
423                launch_template.clone(),
424                "--versions".into(),
425                launch_template_version
426                    .clone()
427                    .unwrap_or_else(|| "$Default".into()),
428            ];
429            if let Some(profile) = aws_profile {
430                launch_args.extend(["--profile".into(), profile.clone()]);
431            }
432            let launch = CommandSpec::new("aws", launch_args)
433                .purpose("verify AWS launch template")
434                .stage(ProvisionStage::Provisioning);
435            let output = executor.execute(&launch)?;
436            ensure!(
437                output.status == 0,
438                "AWS launch-template test failed with status {}: {}",
439                output.status,
440                String::from_utf8_lossy(&output.stderr).trim()
441            );
442            Ok(())
443        }
444        TargetTemplate::LocalBare => Ok(()),
445    }
446}
447
448pub(super) fn backend_bundle(
449    bundle: &ProjectBundle,
450    executor: &impl CommandExecutor,
451) -> Result<ProjectBundleSpec> {
452    let primary = bundle.primary().context("bundle primary is missing")?;
453    Ok(ProjectBundleSpec {
454        primary: primary.destination.to_string_lossy().into_owned(),
455        repositories: bundle
456            .repositories
457            .iter()
458            .map(|repository| {
459                let source = mj_core::remote_git::resolve_repository(repository, executor)
460                    .with_context(|| format!("repository {:?}", repository.id))?;
461                Ok(RepositorySpec {
462                    url: Some(source.fetch_url),
463                    push_urls: source.push_urls,
464                    destination: repository.destination.to_string_lossy().into_owned(),
465                    git_ref: None,
466                    reference: None,
467                })
468            })
469            .collect::<Result<Vec<_>>>()?,
470    })
471}
472
473/// Per-session container size overrides. They win over both the target
474/// template's values and any recorded resource allocation, and they are read
475/// only while a container is being created.
476#[derive(Debug, Clone, Copy, Default)]
477pub(super) struct ContainerOverrides<'a> {
478    pub cpus: Option<&'a str>,
479    pub memory: Option<&'a str>,
480}
481
482impl<'a> ContainerOverrides<'a> {
483    pub(super) fn for_session(session: &'a SessionRecord) -> Self {
484        Self {
485            cpus: session.container_cpus.as_deref(),
486            memory: session.container_memory.as_deref(),
487        }
488    }
489}
490
491pub(super) fn backend_target(
492    template: &TargetTemplate,
493    allocation: Option<&SessionResourceAllocation>,
494    overrides: ContainerOverrides<'_>,
495) -> Result<targets::TargetTemplate> {
496    Ok(match template {
497        TargetTemplate::LocalBare => targets::TargetTemplate::LocalBare,
498        TargetTemplate::LocalPodman { container } => {
499            let mut backend = backend_container(container, allocation, overrides);
500            backend.workspace_storage = (&container.workspace_storage).into();
501            targets::TargetTemplate::LocalPodman(backend)
502        }
503        TargetTemplate::LocalDocker { container } => targets::TargetTemplate::LocalDocker(
504            backend_container(container, allocation, overrides),
505        ),
506        TargetTemplate::AppleContainer { container } => targets::TargetTemplate::AppleContainer(
507            backend_container(container, allocation, overrides),
508        ),
509        TargetTemplate::AwsEc2 {
510            aws_profile,
511            region,
512            launch_template,
513            launch_template_version,
514            ssh_user,
515            identity_file,
516            ssh_args,
517            ..
518        } => targets::TargetTemplate::AwsEc2(AwsTemplate {
519            profile: aws_profile.clone().unwrap_or_else(|| "default".into()),
520            region: region.clone(),
521            launch_template: launch_template.clone(),
522            launch_template_version: launch_template_version.clone(),
523            instance_type: match allocation {
524                Some(SessionResourceAllocation::AwsEc2 { instance_type, .. }) => {
525                    Some(instance_type.clone())
526                }
527                _ => None,
528            },
529            // The address is filled after describe-instances.
530            ssh: SshTarget {
531                destination: format!("{ssh_user}@pending.invalid"),
532                ssh_args: targets::ssh_args_with_identity(ssh_args, identity_file.as_deref()),
533            },
534        }),
535        TargetTemplate::SshBare {
536            ssh,
537            workspace_prefix,
538            ..
539        } => targets::TargetTemplate::SshBare {
540            ssh: SshTarget::from(ssh),
541            workspace_prefix: workspace_prefix.to_string_lossy().into_owned(),
542        },
543        TargetTemplate::SshPodman { ssh, container, .. } => {
544            let mut backend = backend_container(container, allocation, overrides);
545            backend.workspace_storage = (&container.workspace_storage).into();
546            targets::TargetTemplate::SshPodman {
547                ssh: SshTarget::from(ssh),
548                container: backend,
549            }
550        }
551        TargetTemplate::SshDocker { ssh, container, .. } => targets::TargetTemplate::SshDocker {
552            ssh: SshTarget::from(ssh),
553            container: backend_container(container, allocation, overrides),
554        },
555    })
556}
557
558/// Every container image a background refresh keeps current, once per
559/// (host, image, platform).
560///
561/// Targets the host can already satisfy are left out: digest pins, versioned
562/// tags, and the explicit `missing` and `never` policies. Apple's `container`
563/// engine is left out too; it still refreshes its image during provisioning.
564/// Several targets often share one image on one host, and that needs one pull.
565pub fn image_refresh_plan(config: &Config) -> Vec<ImageRefresh> {
566    let mut plan: Vec<ImageRefresh> = Vec::new();
567    for target in config.targets.values() {
568        let (host, container) = match target {
569            TargetTemplate::LocalPodman { container } => (ImageHost::LocalPodman, container),
570            TargetTemplate::LocalDocker { container } => (ImageHost::LocalDocker, container),
571            TargetTemplate::SshPodman { ssh, container } => {
572                (ImageHost::SshPodman(SshTarget::from(ssh)), container)
573            }
574            TargetTemplate::SshDocker { ssh, container } => {
575                (ImageHost::SshDocker(SshTarget::from(ssh)), container)
576            }
577            TargetTemplate::LocalBare
578            | TargetTemplate::AppleContainer { .. }
579            | TargetTemplate::AwsEc2 { .. }
580            | TargetTemplate::SshBare { .. } => continue,
581        };
582        // Commands are decided by the host, the image, and the platform alone,
583        // so equal refreshes are exactly the duplicates worth collapsing.
584        let Some(refresh) = targets::image_refresh(
585            host,
586            &container.image,
587            container.platform.as_deref(),
588            container.pull_policy,
589        ) else {
590            continue;
591        };
592        if !plan.contains(&refresh) {
593            plan.push(refresh);
594        }
595    }
596    plan
597}
598
599pub(crate) fn controller_github_token() -> Option<String> {
600    for name in ["GH_TOKEN", "GITHUB_TOKEN"] {
601        if let Ok(token) = std::env::var(name)
602            && let Some(token) = usable_github_token(&token)
603        {
604            return Some(token.to_owned());
605        }
606    }
607    let output = match Command::new("gh")
608        .args(["auth", "token", "--hostname", "github.com"])
609        .stdin(Stdio::null())
610        .stderr(Stdio::null())
611        .output()
612    {
613        Ok(output) => output,
614        Err(error) => {
615            tracing::debug!(%error, "could not query the GitHub CLI for a token");
616            return None;
617        }
618    };
619    if !output.status.success() {
620        tracing::debug!(status = ?output.status, "GitHub CLI did not return an authenticated token");
621        return None;
622    }
623    let token = match std::str::from_utf8(&output.stdout) {
624        Ok(token) => token,
625        Err(error) => {
626            tracing::debug!(%error, "GitHub CLI returned a non-UTF-8 token");
627            return None;
628        }
629    };
630    let Some(token) = usable_github_token(token) else {
631        tracing::debug!("GitHub CLI returned an empty or invalid token");
632        return None;
633    };
634    Some(token.to_owned())
635}
636
637fn usable_github_token(token: &str) -> Option<&str> {
638    let token = token.trim();
639    (!token.is_empty() && !token.chars().any(char::is_whitespace)).then_some(token)
640}
641
642pub(super) fn configure_github_token_environment(target: &mut targets::TargetTemplate) -> bool {
643    let container = match target {
644        targets::TargetTemplate::LocalPodman(container)
645        | targets::TargetTemplate::LocalDocker(container)
646        | targets::TargetTemplate::AppleContainer(container)
647        | targets::TargetTemplate::SshPodman { container, .. }
648        | targets::TargetTemplate::SshDocker { container, .. } => container,
649        targets::TargetTemplate::LocalBare
650        | targets::TargetTemplate::AwsEc2(_)
651        | targets::TargetTemplate::SshBare { .. } => return false,
652    };
653    container
654        .extra_run_args
655        .extend(["--env".to_owned(), "GH_TOKEN".to_owned()]);
656    true
657}
658
659pub(super) fn use_github_https_urls(bundle: &mut targets::ProjectBundleSpec) {
660    for repository in &mut bundle.repositories {
661        for source in repository
662            .url
663            .iter_mut()
664            .chain(repository.push_urls.iter_mut())
665        {
666            if let Some(github) = crate::setup::github_repository_from_origin(source) {
667                *source = format!(
668                    "https://github.com/{}/{}.git",
669                    github.owner, github.repository
670                );
671            }
672        }
673    }
674}
675
676fn backend_container(
677    container: &mj_core::config::ContainerTemplate,
678    allocation: Option<&SessionResourceAllocation>,
679    overrides: ContainerOverrides<'_>,
680) -> ContainerTemplate {
681    let mut extra_run_args = Vec::new();
682    if let Some(platform) = &container.platform {
683        extra_run_args.push(format!("--platform={platform}"));
684    }
685    let (cpus, memory) = match allocation {
686        Some(SessionResourceAllocation::Container { cpus, memory_bytes }) => {
687            (Some(cpus.to_string()), Some(memory_bytes.to_string()))
688        }
689        _ => (container.cpus.clone(), container.memory.clone()),
690    };
691    // The session's own overrides are the last word on size.
692    let cpus = overrides.cpus.map(str::to_owned).or(cpus);
693    let memory = overrides.memory.map(str::to_owned).or(memory);
694    if let Some(cpus) = cpus {
695        extra_run_args.push(format!("--cpus={cpus}"));
696    }
697    if let Some(memory) = memory {
698        extra_run_args.push(format!("--memory={memory}"));
699    }
700    for (key, value) in &container.environment {
701        extra_run_args.extend(["--env".to_string(), format!("{key}={value}")]);
702    }
703    ContainerTemplate {
704        image: container.image.clone(),
705        pull_policy: container.pull_policy,
706        extra_run_args,
707        workspace_storage: targets::PodmanWorkspaceStorage::ContainerLayer,
708        build_cache: container.build_cache.clone(),
709    }
710}
711
712pub(super) fn validate_resource_allocation(
713    template: &TargetTemplate,
714    allocation: Option<&SessionResourceAllocation>,
715) -> Result<()> {
716    if let Some(allocation) = allocation {
717        allocation.validate()?;
718    }
719    match (template, allocation) {
720        (_, None)
721        | (
722            TargetTemplate::LocalPodman { .. }
723            | TargetTemplate::LocalDocker { .. }
724            | TargetTemplate::AppleContainer { .. }
725            | TargetTemplate::SshPodman { .. }
726            | TargetTemplate::SshDocker { .. },
727            Some(SessionResourceAllocation::Container { .. }),
728        )
729        | (TargetTemplate::AwsEc2 { .. }, Some(SessionResourceAllocation::AwsEc2 { .. })) => Ok(()),
730        (TargetTemplate::LocalBare | TargetTemplate::SshBare { .. }, Some(_)) => {
731            bail!("bare targets have fixed host resources")
732        }
733        _ => bail!("resource allocation does not match the selected target kind"),
734    }
735}
736
737/// How long a freshly launched EC2 instance may take to accept SSH.
738const AWS_SSH_READY_TIMEOUT: Duration = Duration::from_secs(300);
739
740const AWS_SSH_READY_RETRY_DELAY: Duration = Duration::from_secs(3);
741
742/// Poll a remote host until it accepts SSH, or until the deadline passes.
743///
744/// `now` and `sleep` are injected so tests can drive the deadline without
745/// waiting in real time.
746fn wait_for_ssh_ready(
747    executor: &impl CommandExecutor,
748    probe: &CommandSpec,
749    timeout: Duration,
750    mut now: impl FnMut() -> Instant,
751    mut sleep: impl FnMut(Duration),
752) -> Result<()> {
753    let started = now();
754    loop {
755        if executor.cancellation_requested() {
756            bail!("cancelled while waiting for SSH on the new instance");
757        }
758        let failure = match executor.execute(probe) {
759            Ok(output) if output.status == 0 => return Ok(()),
760            Ok(output) => String::from_utf8_lossy(&output.stderr).trim().to_string(),
761            Err(error) => error.to_string(),
762        };
763        if now().duration_since(started) >= timeout {
764            bail!(
765                "{} timed out after {}s: {}",
766                probe.purpose,
767                timeout.as_secs(),
768                if failure.is_empty() {
769                    "the SSH probe reported no error output"
770                } else {
771                    failure.as_str()
772                }
773            );
774        }
775        sleep(AWS_SSH_READY_RETRY_DELAY);
776    }
777}
778
779pub(super) fn locator_after_provision(
780    canonical: &TargetTemplate,
781    backend: &targets::TargetTemplate,
782    session_id: &str,
783    first_output: Option<&CommandOutput>,
784    executor: &(impl CommandExecutor + Sync),
785) -> Result<TargetLocator> {
786    let generated = targets::resource_name(session_id)?;
787    Ok(match canonical {
788        TargetTemplate::LocalBare => TargetLocator::LocalBare {
789            worker_root: data_dir().join("workers").join(session_id),
790        },
791        TargetTemplate::LocalPodman { .. } => {
792            let targets::TargetTemplate::LocalPodman(container) = backend else {
793                bail!("session locator/template mismatch")
794            };
795            TargetLocator::LocalPodman {
796                borrowed_from: None,
797                container_id: generated,
798                workspace_storage: PodmanWorkspaceLocator::from(targets::podman_workspace_locator(
799                    container, session_id,
800                )?),
801            }
802        }
803        TargetTemplate::LocalDocker { .. } => TargetLocator::LocalDocker {
804            borrowed_from: None,
805            container_id: generated,
806        },
807        TargetTemplate::AppleContainer { .. } => TargetLocator::AppleContainer {
808            borrowed_from: None,
809            container_id: generated,
810        },
811        TargetTemplate::SshBare { ssh, .. } => TargetLocator::SshBare {
812            host: ssh.host.clone(),
813            workspace: PathBuf::from(targets::workspace_for(backend, session_id)?),
814            worker_id: None,
815        },
816        TargetTemplate::SshPodman { ssh, .. } => {
817            let targets::TargetTemplate::SshPodman { container, .. } = backend else {
818                bail!("session locator/template mismatch")
819            };
820            TargetLocator::SshPodman {
821                borrowed_from: None,
822                host: ssh.host.clone(),
823                container_id: generated,
824                workspace_storage: PodmanWorkspaceLocator::from(targets::podman_workspace_locator(
825                    container, session_id,
826                )?),
827            }
828        }
829        TargetTemplate::SshDocker { ssh, .. } => TargetLocator::SshDocker {
830            borrowed_from: None,
831            host: ssh.host.clone(),
832            container_id: generated,
833        },
834        TargetTemplate::AwsEc2 {
835            aws_profile,
836            region,
837            ssh_user,
838            address_source,
839            identity_file,
840            ssh_args,
841            ..
842        } => {
843            let output = first_output.context("AWS launch produced no output")?;
844            let json: serde_json::Value = serde_json::from_slice(&output.stdout)
845                .context("parse aws ec2 run-instances response")?;
846            let instance_id = json
847                .pointer("/Instances/0/InstanceId")
848                .and_then(serde_json::Value::as_str)
849                .context("AWS response omitted instance ID")?
850                .to_string();
851            let profile = aws_profile.clone().unwrap_or_else(|| "default".into());
852            execute_checked(
853                executor,
854                CommandSpec::new(
855                    "aws",
856                    [
857                        "--profile".into(),
858                        profile.clone(),
859                        "--region".into(),
860                        region.clone(),
861                        "ec2".into(),
862                        "wait".into(),
863                        "instance-running".into(),
864                        "--instance-ids".into(),
865                        instance_id.clone(),
866                    ],
867                )
868                .purpose("wait for EC2 session instance to run")
869                .stage(ProvisionStage::Booting),
870            )?;
871            let field = match address_source {
872                AwsAddressSource::PublicDns => "PublicDnsName",
873                AwsAddressSource::PublicIp => "PublicIpAddress",
874                AwsAddressSource::PrivateDns => "PrivateDnsName",
875                AwsAddressSource::PrivateIp => "PrivateIpAddress",
876            };
877            let address = execute_checked(
878                executor,
879                CommandSpec::new(
880                    "aws",
881                    [
882                        "--profile".into(),
883                        profile.clone(),
884                        "--region".into(),
885                        region.clone(),
886                        "ec2".into(),
887                        "describe-instances".into(),
888                        "--instance-ids".into(),
889                        instance_id.clone(),
890                        "--query".into(),
891                        format!("Reservations[0].Instances[0].{field}"),
892                        "--output".into(),
893                        "text".into(),
894                    ],
895                )
896                .purpose("resolve EC2 session address")
897                .stage(ProvisionStage::Booting),
898            )?;
899            let address = String::from_utf8(address.stdout)
900                .context("AWS address was not UTF-8")?
901                .trim()
902                .to_string();
903            if address.is_empty() || address == "None" {
904                bail!("AWS instance {instance_id} has no configured address");
905            }
906            let ssh = SshTarget {
907                destination: format!("{ssh_user}@{address}"),
908                ssh_args: targets::ssh_args_with_identity(ssh_args, identity_file.as_deref()),
909            };
910            wait_for_ssh_ready(
911                executor,
912                &crate::targets::ssh_command(&ssh, ["true"])
913                    .purpose("wait for EC2 SSH availability")
914                    .stage(ProvisionStage::Booting),
915                AWS_SSH_READY_TIMEOUT,
916                Instant::now,
917                std::thread::sleep,
918            )?;
919            TargetLocator::AwsEc2 {
920                instance_id,
921                address: Some(address),
922            }
923        }
924    })
925}
926
927/// The execution-plan locator for a session's stored target.
928///
929/// The mapping itself lives in `mj_core::targets`; this only pairs the stored
930/// locator with the target template the session was created against.
931pub(super) fn backend_locator(
932    locator: &TargetLocator,
933    session: &SessionRecord,
934    config: &Config,
935) -> Result<targets::TargetLocator> {
936    let template = config
937        .targets
938        .get(&session.target_template_id)
939        .context("session target template is missing")?;
940    Ok(targets::TargetLocator::try_from(targets::StoredTarget {
941        locator,
942        template,
943        session_id: &session.id,
944    })?)
945}
946
947#[cfg(test)]
948mod tests;