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