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::time::{Duration, Instant};
6
7use anyhow::{Context, Result, bail, ensure};
8
9use mj_core::config::{AwsAddressSource, Config, ProjectBundle, TargetTemplate, data_dir};
10use mj_core::state::{
11    PodmanWorkspaceLocator, ResourceAllocationKind, SessionRecord, SessionResourceAllocation,
12    TargetLocator, allocation_cpus,
13};
14
15use crate::targets::{
16    self, AwsTemplate, CommandExecutor, CommandOutput, CommandSpec, ContainerTemplate,
17    ImageRefresh, ProjectBundleSpec, ProvisionStage, RepositorySpec, SshTarget,
18};
19
20use super::{Controller, execute_checked};
21
22impl Controller {
23    /// Inspect the actual execution checkout in a background worker.
24    pub fn session_working_context(
25        &self,
26        session_id: &str,
27        executor: &impl CommandExecutor,
28    ) -> Result<(PathBuf, String)> {
29        let session = self
30            .state
31            .sessions
32            .get(session_id)
33            .context("session is missing")?;
34        let locator = session
35            .target
36            .as_ref()
37            .context("target is still starting")?;
38        let backend = backend_locator(locator, session, &self.config)?;
39        let launch = self.current_worker_launch_config(session_id, &backend)?;
40        let output = executor.execute(&targets::command_on_locator(
41            &backend,
42            session_id,
43            vec![
44                "git".into(),
45                "-C".into(),
46                launch.cwd.to_string_lossy().into_owned(),
47                "rev-parse".into(),
48                "--abbrev-ref".into(),
49                "HEAD".into(),
50            ],
51            "read current session branch",
52        )?)?;
53        let branch = if output.status == 0 {
54            let branch = String::from_utf8(output.stdout).context("decode session branch")?;
55            if branch.trim() == "HEAD" {
56                "detached HEAD".to_owned()
57            } else {
58                branch.trim().to_owned()
59            }
60        } else {
61            let stderr = String::from_utf8_lossy(&output.stderr);
62            if stderr.contains("not a git repository") {
63                // A plain folder opened through `mj go` has no branch. Say so
64                // briefly rather than echoing multi-line git stderr, which wraps
65                // the banner.
66                "not a git checkout".to_owned()
67            } else {
68                format!(
69                    "unavailable: {}",
70                    stderr.lines().next().unwrap_or("").trim()
71                )
72            }
73        };
74        Ok((launch.cwd, branch))
75    }
76
77    /// The session checkout's branch, distance from upstream, and changed
78    /// files, read with the target's own `git`. Builds on
79    /// [`Self::session_working_context`], so it works wherever that does: a
80    /// local checkout, a container, or an SSH host.
81    pub fn session_git_status(
82        &self,
83        session_id: &str,
84        executor: &impl CommandExecutor,
85    ) -> Result<mj_core::local_git::SessionGitStatus> {
86        let (cwd, branch) = self.session_working_context(session_id, executor)?;
87        if branch.starts_with("not a git") || branch.starts_with("unavailable") {
88            return Ok(mj_core::local_git::parse_git_status(
89                cwd, &branch, None, "", "",
90            ));
91        }
92        let session = self
93            .state
94            .sessions
95            .get(session_id)
96            .context("session is missing")?;
97        let locator = session
98            .target
99            .as_ref()
100            .context("target is still starting")?;
101        let backend = backend_locator(locator, session, &self.config)?;
102        let cwd_text = cwd.to_string_lossy().into_owned();
103        let run = |args: &[&str], purpose: &str| -> Result<Option<String>> {
104            let mut command = vec!["git".to_owned(), "-C".to_owned(), cwd_text.clone()];
105            command.extend(args.iter().map(|arg| (*arg).to_owned()));
106            let output = executor.execute(&targets::command_on_locator(
107                &backend, session_id, command, purpose,
108            )?)?;
109            Ok((output.status == 0).then(|| String::from_utf8_lossy(&output.stdout).into_owned()))
110        };
111        // No upstream is an ordinary state, so a failing count is `None`
112        // rather than an error.
113        let ahead_behind = run(
114            &["rev-list", "--left-right", "--count", "@{upstream}...HEAD"],
115            "count commits against upstream",
116        )?;
117        // A repository with no commit yet has no HEAD to diff against.
118        let numstat = run(
119            &["--no-optional-locks", "diff", "--numstat", "HEAD"],
120            "count changed lines",
121        )?
122        .unwrap_or_default();
123        let porcelain = run(
124            &[
125                "--no-optional-locks",
126                "status",
127                "--porcelain",
128                "--untracked-files=normal",
129            ],
130            "list changed files",
131        )?
132        .unwrap_or_default();
133        Ok(mj_core::local_git::parse_git_status(
134            cwd,
135            &branch,
136            ahead_behind.as_deref(),
137            &numstat,
138            &porcelain,
139        ))
140    }
141
142    pub fn resolve_aws_resource_options(
143        &self,
144        target_id: &str,
145        executor: &impl CommandExecutor,
146    ) -> Result<Vec<SessionResourceAllocation>> {
147        let TargetTemplate::AwsEc2 {
148            aws_profile,
149            region,
150            launch_template,
151            launch_template_version,
152            ..
153        } = self
154            .config
155            .targets
156            .get(target_id)
157            .with_context(|| format!("unknown target template {target_id:?}"))?
158        else {
159            bail!("target {target_id:?} is not an AWS EC2 target");
160        };
161        let profile = aws_profile.as_deref().unwrap_or("default");
162        let launch_key = if launch_template.starts_with("lt-") {
163            "--launch-template-id"
164        } else {
165            "--launch-template-name"
166        };
167        let version = launch_template_version.as_deref().unwrap_or("$Default");
168        let describe_template = CommandSpec::new(
169            "aws",
170            [
171                "--profile",
172                profile,
173                "--region",
174                region,
175                "ec2",
176                "describe-launch-template-versions",
177                launch_key,
178                launch_template,
179                "--versions",
180                version,
181                "--output",
182                "json",
183            ],
184        )
185        .purpose("resolve EC2 launch template instance family");
186        let output = executor.execute(&describe_template)?;
187        if output.status != 0 {
188            bail!(
189                "{} failed with status {}: {}",
190                describe_template.purpose,
191                output.status,
192                String::from_utf8_lossy(&output.stderr).trim()
193            );
194        }
195        let response: serde_json::Value =
196            serde_json::from_slice(&output.stdout).context("parse EC2 launch template response")?;
197        let instance_type = response
198            .pointer("/LaunchTemplateVersions/0/LaunchTemplateData/InstanceType")
199            .and_then(serde_json::Value::as_str)
200            .context("launch template does not specify a concrete instance type")?;
201        let family = instance_type
202            .rsplit_once('.')
203            .map(|(family, _)| family)
204            .context("launch template instance type has no size suffix")?;
205        let filter = format!("Name=instance-type,Values={family}.*");
206        let describe_types = CommandSpec::new(
207            "aws",
208            [
209                "--profile",
210                profile,
211                "--region",
212                region,
213                "ec2",
214                "describe-instance-types",
215                "--filters",
216                &filter,
217                "--output",
218                "json",
219            ],
220        )
221        .purpose("discover EC2 instance sizes");
222        let output = executor.execute(&describe_types)?;
223        if output.status != 0 {
224            bail!(
225                "{} failed with status {}: {}",
226                describe_types.purpose,
227                output.status,
228                String::from_utf8_lossy(&output.stderr).trim()
229            );
230        }
231        let response: serde_json::Value =
232            serde_json::from_slice(&output.stdout).context("parse EC2 instance type response")?;
233        let mut options = response
234            .get("InstanceTypes")
235            .and_then(serde_json::Value::as_array)
236            .context("EC2 instance type response omitted InstanceTypes")?
237            .iter()
238            .filter_map(|entry| {
239                Some(SessionResourceAllocation::AwsEc2 {
240                    instance_type: entry.get("InstanceType")?.as_str()?.to_owned(),
241                    vcpus: entry.pointer("/VCpuInfo/DefaultVCpus")?.as_u64()?,
242                    memory_bytes: entry
243                        .pointer("/MemoryInfo/SizeInMiB")?
244                        .as_u64()?
245                        .checked_mul(1024 * 1024)?,
246                })
247            })
248            .collect::<Vec<_>>();
249        options.sort_by_key(allocation_cpus);
250        if !options.iter().any(|option| allocation_cpus(option) == 8) {
251            bail!("EC2 family {family:?} has no exact 8-vCPU baseline size");
252        }
253        Ok(options)
254    }
255
256    pub fn reconnect_command(&self, session_id: &str) -> Result<CommandSpec> {
257        let session = self
258            .state
259            .sessions
260            .get(session_id)
261            .with_context(|| format!("unknown session {session_id}"))?;
262        session.validate_configuration(&self.config)?;
263        let locator = session.target.as_ref().context("session has no target")?;
264        let backend = backend_locator(locator, session, &self.config)?;
265        targets::reconnect_plan(&backend, session_id)?
266            .commands
267            .into_iter()
268            .next()
269            .context("reconnect plan is empty")
270    }
271
272    /// The directories the live sessions on one target write to.
273    fn session_storage_paths(&self, target_id: &str) -> Vec<String> {
274        self.state
275            .sessions
276            .values()
277            .filter(|session| session.target_template_id == target_id && session.state.is_active())
278            .filter_map(|session| {
279                let checkout = self.state.checkout(&session.id).ok()?;
280                let paths = mj_core::targets::storage::session_storage_paths(
281                    session.target.as_ref()?,
282                    &session.id,
283                    checkout.project_directory(),
284                );
285                Some(paths.all().map(str::to_owned).collect::<Vec<_>>())
286            })
287            .flatten()
288            .collect()
289    }
290
291    pub fn deployment_capacity_targets(&self) -> Vec<targets::DeploymentCapacityTarget> {
292        use targets::{DeploymentCapacityKind, DeploymentCapacityTarget};
293
294        let mut local_ids = Vec::new();
295        // Per host: its target ids, and per SSH connection the paths to measure.
296        type HostProbes = (Vec<String>, Vec<(SshTarget, Vec<String>)>);
297        let mut ssh_hosts: BTreeMap<String, HostProbes> = BTreeMap::new();
298        let mut targets = Vec::new();
299        for (target_id, template) in &self.config.targets {
300            match template {
301                TargetTemplate::LocalBare
302                | TargetTemplate::LocalPodman { .. }
303                | TargetTemplate::LocalDocker { .. }
304                | TargetTemplate::AppleContainer { .. } => {
305                    local_ids.push(target_id.clone());
306                }
307                TargetTemplate::SshBare { ssh, .. }
308                | TargetTemplate::SshPodman { ssh, .. }
309                | TargetTemplate::SshDocker { ssh, .. } => {
310                    let entry = ssh_hosts.entry(ssh.host.clone()).or_default();
311                    entry.0.push(target_id.clone());
312                    let connection = SshTarget::from(ssh);
313                    let paths = match entry.1.iter_mut().find(|(known, _)| *known == connection) {
314                        Some((_, paths)) => paths,
315                        None => {
316                            entry.1.push((connection, Vec::new()));
317                            &mut entry.1.last_mut().expect("just pushed").1
318                        }
319                    };
320                    push_unique(paths, ssh_storage_paths(template));
321                    // Each live session's own directories: a project or
322                    // clone can sit on a filesystem of its own.
323                    push_unique(paths, self.session_storage_paths(target_id));
324                }
325                TargetTemplate::AwsEc2 { .. } => {
326                    let mut probes = Vec::new();
327                    let mut probe_error = None;
328                    for session in self.state.sessions.values().filter(|session| {
329                        session.target_template_id == *target_id
330                            && session.state.is_active()
331                            && session.target.is_some()
332                    }) {
333                        let result = backend_locator(
334                            session.target.as_ref().expect("filtered target"),
335                            session,
336                            &self.config,
337                        )
338                        .and_then(|locator| {
339                            targets::aws_allocated_capacity_command(&locator, &session.id)
340                        });
341                        match result {
342                            Ok(command) => probes.push(command),
343                            Err(error) => probe_error = Some(format!("{error:#}")),
344                        }
345                    }
346                    targets.push(DeploymentCapacityTarget {
347                        id: format!("aws:{target_id}"),
348                        host: target_id.clone(),
349                        target_ids: vec![target_id.clone()],
350                        kind: DeploymentCapacityKind::AwsFleet,
351                        local: false,
352                        probes,
353                        local_storage_paths: Vec::new(),
354                        probe_error,
355                    });
356                }
357            }
358        }
359        if !local_ids.is_empty() {
360            // CPU and memory come from sysinfo; storage is measured over the
361            // paths by `targets::measure_local_storage`.
362            let mut paths = Vec::new();
363            let mbx_paths = super::mbx::local_storage_paths();
364            for target_id in &local_ids {
365                if let Some(template) = self.config.targets.get(target_id) {
366                    push_unique(&mut paths, local_storage_paths(template, &mbx_paths));
367                }
368                push_unique(&mut paths, self.session_storage_paths(target_id));
369            }
370            targets.push(DeploymentCapacityTarget {
371                id: "local".into(),
372                host: "local".into(),
373                target_ids: local_ids,
374                kind: DeploymentCapacityKind::Host,
375                local: true,
376                probes: Vec::new(),
377                local_storage_paths: paths,
378                probe_error: None,
379            });
380        }
381        targets.extend(
382            ssh_hosts
383                .into_iter()
384                .map(
385                    |(host, (target_ids, connections))| DeploymentCapacityTarget {
386                        id: format!("ssh:{host}"),
387                        host,
388                        target_ids,
389                        kind: DeploymentCapacityKind::Host,
390                        local: false,
391                        probes: connections
392                            .iter()
393                            .map(|(ssh, paths)| targets::ssh_host_capacity_command(ssh, paths))
394                            .collect(),
395                        local_storage_paths: Vec::new(),
396                        probe_error: None,
397                    },
398                ),
399        );
400        targets.sort_by(|left, right| left.id.cmp(&right.id));
401        targets
402    }
403
404    /// The full check behind the Targets pane's Test action.
405    pub fn test_target(&self, target_id: &str, executor: &impl CommandExecutor) -> Result<()> {
406        verify_target(
407            self.configured_target(target_id)?,
408            executor,
409            TargetCheck::BeforeLaunch,
410        )
411    }
412
413    /// The check a session wizard runs before it offers a target: the same
414    /// one a launch runs, worded for a launch that has not happened yet.
415    pub fn check_target_readiness(
416        &self,
417        target_id: &str,
418        executor: &impl CommandExecutor,
419    ) -> Result<()> {
420        preflight_target(
421            self.configured_target(target_id)?,
422            executor,
423            TargetCheck::BeforeLaunch,
424        )
425    }
426
427    fn configured_target(&self, target_id: &str) -> Result<&TargetTemplate> {
428        self.config
429            .targets
430            .get(target_id)
431            .with_context(|| format!("unknown target template {target_id:?}"))
432    }
433}
434
435/// When a target is checked, which decides how its refusal ends.
436#[derive(Debug, Clone, Copy, PartialEq, Eq)]
437pub(super) enum TargetCheck {
438    /// Before anything is launched: the session wizard's target row, the
439    /// Targets pane.
440    BeforeLaunch,
441    /// Inside a launch, resume, or move, whose failure the launch-failure
442    /// dialog reports with its Retry launch button.
443    Launch,
444}
445
446impl TargetCheck {
447    /// The clause that points at the failure dialog's Retry launch. Before a
448    /// launch there is no such dialog to point at (launch finding R6-3).
449    const fn then_retry(self) -> &'static str {
450        match self {
451            Self::BeforeLaunch => "",
452            Self::Launch => ", then Retry launch",
453        }
454    }
455}
456
457/// The directories Mjolnir writes to on an SSH host for one target, relative
458/// to the SSH user's home where they live there: worker roots, the upload
459/// staging and binary caches, and the workspace prefix or the container
460/// engine's storage. Each is measured at its nearest existing ancestor.
461pub(crate) fn ssh_storage_paths(template: &TargetTemplate) -> Vec<String> {
462    use mj_core::targets::storage::{
463        ContainerStorage, DEFAULT_BUILD_CACHE_DIRECTORY, REMOTE_CACHE_DIRECTORY,
464        REMOTE_PROFILES_DIRECTORY, REMOTE_WORKERS_DIRECTORY, TEMPORARY_DIRECTORY,
465    };
466    let mut paths = vec![
467        REMOTE_CACHE_DIRECTORY.to_owned(),
468        DEFAULT_BUILD_CACHE_DIRECTORY.to_owned(),
469        TEMPORARY_DIRECTORY.to_owned(),
470    ];
471    match template {
472        TargetTemplate::SshBare {
473            workspace_prefix, ..
474        } => {
475            paths.push(REMOTE_WORKERS_DIRECTORY.to_owned());
476            paths.push(REMOTE_PROFILES_DIRECTORY.to_owned());
477            paths.push(workspace_prefix.to_string_lossy().into_owned());
478        }
479        TargetTemplate::SshPodman { .. } => paths.push(ContainerStorage::Podman.path().to_owned()),
480        TargetTemplate::SshDocker { .. } => paths.push(ContainerStorage::Docker.path().to_owned()),
481        _ => {}
482    }
483    paths.extend(build_cache_directory(template));
484    paths
485}
486
487/// A target's configured mbx cache directory, when it names one.
488fn build_cache_directory(template: &TargetTemplate) -> Option<String> {
489    template
490        .container()?
491        .build_cache
492        .as_ref()?
493        .directory
494        .as_ref()
495        .map(|directory| directory.to_string_lossy().into_owned())
496}
497
498/// The paths the local probe measures for one local target.
499fn local_storage_paths(template: &TargetTemplate, mbx_paths: &[PathBuf]) -> Vec<String> {
500    use mj_core::targets::storage::{ContainerStorage, TEMPORARY_DIRECTORY, local_home_path};
501    let mut paths = vec![
502        data_dir().to_string_lossy().into_owned(),
503        TEMPORARY_DIRECTORY.to_owned(),
504    ];
505    paths.extend(
506        mbx_paths
507            .iter()
508            .map(|path| path.to_string_lossy().into_owned()),
509    );
510    match template {
511        TargetTemplate::LocalPodman { .. } => {
512            paths.push(local_home_path(ContainerStorage::Podman.path()));
513        }
514        TargetTemplate::LocalDocker { .. } => {
515            paths.push(ContainerStorage::Docker.path().to_owned());
516        }
517        _ => {}
518    }
519    paths.extend(build_cache_directory(template));
520    paths
521}
522
523fn push_unique(paths: &mut Vec<String>, more: impl IntoIterator<Item = String>) {
524    for path in more {
525        if !path.is_empty() && !paths.contains(&path) {
526            paths.push(path);
527        }
528    }
529}
530
531/// Check a target before a launch, resume, or move uses it.
532///
533/// An SSH host's container runtime is verified by `mj setup`, `mj doctor`,
534/// and the Targets pane's Test action; here it is assumed to still be as
535/// they left it, and only the host's reachability is checked. That probe
536/// joins an open shared connection, so it usually costs one SSH channel.
537pub(super) fn preflight_target(
538    template: &TargetTemplate,
539    executor: &impl CommandExecutor,
540    check: TargetCheck,
541) -> Result<()> {
542    match template {
543        TargetTemplate::SshBare { ssh, .. }
544        | TargetTemplate::SshPodman { ssh, .. }
545        | TargetTemplate::SshDocker { ssh, .. } => {
546            verify_ssh_connectivity(&SshTarget::from(ssh), executor)
547        }
548        _ => verify_target(template, executor, check),
549    }
550}
551
552/// Whether a local container engine can run sessions, as the launch
553/// preflight and the dashboard's Targets pane judge it.
554#[derive(Debug, Clone, Copy, PartialEq, Eq)]
555pub(crate) enum LocalEngineReadiness {
556    Ready,
557    /// The engine's command is not on this host.
558    NotInstalled,
559    /// The command is there, but the engine did not answer its check.
560    NotReady,
561}
562
563/// Run the launch preflight's engine check for a local container target
564/// kind (`local-podman`, `local-docker`, or `apple-container`).
565///
566/// Answers `None` for any other kind: their readiness is not a local engine's.
567pub(crate) fn local_engine_readiness(
568    kind: &str,
569    executor: &impl CommandExecutor,
570) -> Option<LocalEngineReadiness> {
571    let result = match kind {
572        "local-podman" => targets::verify_local_podman(executor).map(|_| ()),
573        "local-docker" => targets::verify_local_docker(executor).map(|_| ()),
574        // The same command `verify_target` runs, without its sentence, which
575        // flattens the cause this needs to tell a missing command apart.
576        "apple-container" => executor
577            .execute(
578                &CommandSpec::new("container", ["system", "status"])
579                    .purpose("preflight Apple container runtime")
580                    .stage(ProvisionStage::Provisioning),
581            )
582            .and_then(|output| {
583                ensure!(output.status == 0, "container system status failed");
584                Ok(())
585            }),
586        _ => return None,
587    };
588    Some(match result {
589        Ok(()) => LocalEngineReadiness::Ready,
590        Err(error) if is_missing_command(&error) => LocalEngineReadiness::NotInstalled,
591        Err(_) => LocalEngineReadiness::NotReady,
592    })
593}
594
595/// Whether a command failed because its program is not installed.
596fn is_missing_command(error: &anyhow::Error) -> bool {
597    error.chain().any(|cause| {
598        cause
599            .downcast_ref::<std::io::Error>()
600            .is_some_and(|io| io.kind() == std::io::ErrorKind::NotFound)
601    })
602}
603
604/// Check that a target's host and runtime can run sessions.
605pub(super) fn verify_target(
606    template: &TargetTemplate,
607    executor: &impl CommandExecutor,
608    check: TargetCheck,
609) -> Result<()> {
610    let then_retry = check.then_retry();
611    match template {
612        TargetTemplate::LocalPodman { .. } => targets::verify_local_podman(executor)
613            .map(|_| ())
614            .map_err(|error| {
615                anyhow::anyhow!(
616                    "local Podman is not ready. Fix the problem below{then_retry}: {error:#}"
617                )
618            }),
619        TargetTemplate::LocalDocker { .. } => targets::verify_local_docker(executor)
620            .map(|_| ())
621            .map_err(
622                |error| match error.downcast_ref::<targets::DockerUnavailable>() {
623                    // Leads with the sentence the launch options use, so a
624                    // missing Docker reads "not installed" in the wizard's
625                    // row too (launch finding R5-3).
626                    Some(problem) => anyhow::anyhow!(
627                        "{problem} {}",
628                        match check {
629                            TargetCheck::BeforeLaunch => problem.remedy(),
630                            TargetCheck::Launch => problem.launch_remedy(),
631                        }
632                    ),
633                    None => anyhow::anyhow!(
634                        "local Docker is not ready. Start Docker or fix the problem below{then_retry}: {error:#}"
635                    ),
636                },
637            ),
638        TargetTemplate::SshPodman { ssh, .. } => {
639            let ssh = SshTarget::from(ssh);
640            targets::verify_ssh_podman(&ssh, executor)
641                .map(|preflight| {
642                    for warning in preflight.warnings {
643                        executor.notify_notice(&warning.notice());
644                    }
645                })
646                .map_err(|error| {
647                    anyhow::anyhow!(
648                        "remote Podman is not ready on {}. Fix the problem below{then_retry}: {error:#}",
649                        ssh.destination
650                    )
651                })
652        }
653        TargetTemplate::SshDocker { ssh, .. } => {
654            let ssh = SshTarget::from(ssh);
655            targets::verify_ssh_docker(&ssh, executor)
656                .map(|_| ())
657                .map_err(|error| {
658                    anyhow::anyhow!(
659                        "remote Docker preflight failed for {}. Fix the problem below{then_retry}: {error:#}",
660                        ssh.destination
661                    )
662                })
663        }
664        TargetTemplate::AppleContainer { .. } => {
665            let command = CommandSpec::new("container", ["system", "status"])
666                .purpose("preflight Apple container runtime")
667                .stage(ProvisionStage::Provisioning);
668            let output = executor.execute(&command).map_err(|error| {
669                anyhow::anyhow!(
670                    "Apple container is not ready. Fix the problem below{then_retry}: {error}"
671                )
672            })?;
673            if output.status != 0 {
674                bail!(
675                    "Apple container is not ready. Start the runtime with `container system start`{then_retry}: container system status exited {}: {}",
676                    output.status,
677                    [
678                        String::from_utf8_lossy(&output.stdout).trim(),
679                        String::from_utf8_lossy(&output.stderr).trim(),
680                    ]
681                    .into_iter()
682                    .filter(|message| !message.is_empty())
683                    .collect::<Vec<_>>()
684                    .join("\n")
685                );
686            }
687            Ok(())
688        }
689        TargetTemplate::SshBare { ssh, .. } => {
690            verify_ssh_connectivity(&SshTarget::from(ssh), executor)
691        }
692        TargetTemplate::AwsEc2 {
693            aws_profile,
694            region,
695            launch_template,
696            launch_template_version,
697            ..
698        } => {
699            let mut identity_args = vec!["sts".into(), "get-caller-identity".into()];
700            if let Some(profile) = aws_profile {
701                identity_args.extend(["--profile".into(), profile.clone()]);
702            }
703            let identity = CommandSpec::new("aws", identity_args)
704                .purpose("verify AWS credentials")
705                .stage(ProvisionStage::Provisioning);
706            let output = executor.execute(&identity)?;
707            ensure!(
708                output.status == 0,
709                "AWS credential test failed with status {}: {}",
710                output.status,
711                String::from_utf8_lossy(&output.stderr).trim()
712            );
713
714            let mut launch_args = vec![
715                "ec2".into(),
716                "describe-launch-template-versions".into(),
717                "--region".into(),
718                region.clone(),
719                if launch_template.starts_with("lt-") { "--launch-template-id" } else { "--launch-template-name" }.into(),
720                launch_template.clone(),
721                "--versions".into(),
722                launch_template_version
723                    .clone()
724                    .unwrap_or_else(|| "$Default".into()),
725            ];
726            if let Some(profile) = aws_profile {
727                launch_args.extend(["--profile".into(), profile.clone()]);
728            }
729            let launch = CommandSpec::new("aws", launch_args)
730                .purpose("verify AWS launch template")
731                .stage(ProvisionStage::Provisioning);
732            let output = executor.execute(&launch)?;
733            ensure!(
734                output.status == 0,
735                "AWS launch-template test failed with status {}: {}",
736                output.status,
737                String::from_utf8_lossy(&output.stderr).trim()
738            );
739            Ok(())
740        }
741        TargetTemplate::LocalBare => Ok(()),
742    }
743}
744
745fn verify_ssh_connectivity(ssh: &SshTarget, executor: &impl CommandExecutor) -> Result<()> {
746    let output = executor.execute(&targets::ssh_connectivity_probe(ssh))?;
747    ensure!(
748        output.status == 0,
749        "SSH connectivity test failed for {} with status {}: {}",
750        ssh.destination,
751        output.status,
752        String::from_utf8_lossy(&output.stderr).trim()
753    );
754    Ok(())
755}
756
757#[cfg(test)]
758pub(super) fn backend_bundle(
759    bundle: &ProjectBundle,
760    executor: &impl CommandExecutor,
761) -> Result<ProjectBundleSpec> {
762    backend_bundle_with_sources(bundle, None, executor)
763}
764
765pub(super) fn backend_session_bundle(
766    session: &SessionRecord,
767    config: &mj_core::config::Config,
768    executor: &impl CommandExecutor,
769) -> Result<ProjectBundleSpec> {
770    backend_bundle_with_sources(
771        session
772            .project_bundle(config)
773            .context("session bundle is missing")?,
774        session
775            .project
776            .as_ref()
777            .map(|project| &project.network_sources),
778        executor,
779    )
780}
781
782fn backend_bundle_with_sources(
783    bundle: &ProjectBundle,
784    sources: Option<&std::collections::BTreeMap<String, mj_core::remote_git::NetworkGitSource>>,
785    executor: &impl CommandExecutor,
786) -> Result<ProjectBundleSpec> {
787    let primary = bundle.primary().context("bundle primary is missing")?;
788    Ok(ProjectBundleSpec {
789        primary: primary.destination.to_string_lossy().into_owned(),
790        repositories: bundle
791            .repositories
792            .iter()
793            .map(|repository| {
794                let source = match sources.and_then(|sources| sources.get(&repository.id)) {
795                    Some(source) => source.clone(),
796                    None => mj_core::remote_git::resolve_repository(repository, executor)
797                        .with_context(|| format!("repository {:?}", repository.id))?,
798                };
799                Ok(RepositorySpec {
800                    url: Some(source.fetch_url),
801                    push_urls: source.push_urls,
802                    destination: repository.destination.to_string_lossy().into_owned(),
803                    git_ref: None,
804                    reference: None,
805                })
806            })
807            .collect::<Result<Vec<_>>>()?,
808    })
809}
810
811/// Per-session container size overrides. They win over both the target
812/// template's values and any recorded resource allocation, and they are read
813/// only while a container is being created.
814#[derive(Debug, Clone, Copy, Default)]
815pub(super) struct ContainerOverrides<'a> {
816    pub cpus: Option<&'a str>,
817    pub memory: Option<&'a str>,
818}
819
820impl<'a> ContainerOverrides<'a> {
821    pub(super) fn for_session(session: &'a SessionRecord) -> Self {
822        Self {
823            cpus: session.container_cpus.as_deref(),
824            memory: session.container_memory.as_deref(),
825        }
826    }
827}
828
829pub(super) fn backend_target(
830    template: &TargetTemplate,
831    allocation: Option<&SessionResourceAllocation>,
832    overrides: ContainerOverrides<'_>,
833) -> Result<targets::TargetTemplate> {
834    Ok(match template {
835        TargetTemplate::LocalBare => targets::TargetTemplate::LocalBare,
836        TargetTemplate::LocalPodman { container } => {
837            let mut backend = backend_container(container, allocation, overrides);
838            backend.workspace_storage = (&container.workspace_storage).into();
839            targets::TargetTemplate::LocalPodman(backend)
840        }
841        TargetTemplate::LocalDocker { container } => targets::TargetTemplate::LocalDocker(
842            backend_container(container, allocation, overrides),
843        ),
844        TargetTemplate::AppleContainer { container } => targets::TargetTemplate::AppleContainer(
845            backend_container(container, allocation, overrides),
846        ),
847        TargetTemplate::AwsEc2 {
848            aws_profile,
849            region,
850            launch_template,
851            launch_template_version,
852            ssh_user,
853            identity_file,
854            ssh_args,
855            ..
856        } => targets::TargetTemplate::AwsEc2(AwsTemplate {
857            profile: aws_profile.clone().unwrap_or_else(|| "default".into()),
858            region: region.clone(),
859            launch_template: launch_template.clone(),
860            launch_template_version: launch_template_version.clone(),
861            instance_type: match allocation {
862                Some(SessionResourceAllocation::AwsEc2 { instance_type, .. }) => {
863                    Some(instance_type.clone())
864                }
865                _ => None,
866            },
867            // The address is filled after describe-instances.
868            ssh: SshTarget {
869                destination: format!("{ssh_user}@pending.invalid"),
870                ssh_args: targets::ssh_args_with_identity(ssh_args, identity_file.as_deref()),
871            },
872        }),
873        TargetTemplate::SshBare {
874            ssh,
875            workspace_prefix,
876            ..
877        } => targets::TargetTemplate::SshBare {
878            ssh: SshTarget::from(ssh),
879            workspace_prefix: workspace_prefix.to_string_lossy().into_owned(),
880        },
881        TargetTemplate::SshPodman { ssh, container, .. } => {
882            let mut backend = backend_container(container, allocation, overrides);
883            backend.workspace_storage = (&container.workspace_storage).into();
884            targets::TargetTemplate::SshPodman {
885                ssh: SshTarget::from(ssh),
886                container: backend,
887            }
888        }
889        TargetTemplate::SshDocker { ssh, container, .. } => targets::TargetTemplate::SshDocker {
890            ssh: SshTarget::from(ssh),
891            container: backend_container(container, allocation, overrides),
892        },
893    })
894}
895
896/// Every container image the daemon downloads in the background, once per
897/// (host, image, platform).
898///
899/// Every container target is covered, including Apple's `container` engine.
900/// A `never` policy is the one opt-out. The rest differ only in when they
901/// download: `always` and `newer` pull on every refresh, while the others
902/// pull only when the host has no copy of the image.
903///
904/// Several targets often share one image on one host, and that needs one
905/// download. When two such targets disagree about when to pull, the merged
906/// entry takes the more eager of the two.
907pub fn image_refresh_plan(config: &Config) -> Vec<ImageRefresh> {
908    let mut plan: Vec<ImageRefresh> = Vec::new();
909    for target in config.targets.values() {
910        let Some((host, container)) = target.image_host() else {
911            continue;
912        };
913        let Some(refresh) = targets::image_refresh(
914            host,
915            &container.image,
916            container.platform.as_deref(),
917            container.pull_policy,
918        ) else {
919            continue;
920        };
921        // Commands are decided by the host, the image, and the platform alone,
922        // so those three identify the duplicates worth collapsing.
923        if let Some(existing) = plan.iter_mut().find(|entry| {
924            entry.host == refresh.host
925                && entry.image == refresh.image
926                && entry.platform == refresh.platform
927        }) {
928            existing.when = existing.when.max(refresh.when);
929            continue;
930        }
931        plan.push(refresh);
932    }
933    plan
934}
935
936/// Where the controller's GitHub credential came from, so an error about a
937/// rejected token can name the setting to fix.
938#[derive(Debug, Clone, Copy, PartialEq, Eq)]
939pub(crate) enum GithubTokenSource {
940    GhTokenEnv,
941    GithubTokenEnv,
942    GhCli,
943}
944
945impl std::fmt::Display for GithubTokenSource {
946    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
947        f.write_str(match self {
948            Self::GhTokenEnv => "GH_TOKEN",
949            Self::GithubTokenEnv => "GITHUB_TOKEN",
950            Self::GhCli => "the GitHub CLI login",
951        })
952    }
953}
954
955pub(crate) struct GithubCredential {
956    pub(crate) token: String,
957    pub(crate) source: GithubTokenSource,
958}
959
960impl std::fmt::Debug for GithubCredential {
961    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
962        f.debug_struct("GithubCredential")
963            .field("token", &"<redacted>")
964            .field("source", &self.source)
965            .finish()
966    }
967}
968
969/// `gh auth token` reads local state; a keychain prompt or a wedged credential
970/// helper must not hold the caller, and the bounded runner kills it on expiry.
971const GH_AUTH_TOKEN_TIMEOUT: Duration = Duration::from_secs(5);
972
973pub(crate) async fn controller_github_token() -> Option<String> {
974    controller_github_credential()
975        .await
976        .map(|credential| credential.token)
977}
978
979pub(crate) async fn controller_github_credential() -> Option<GithubCredential> {
980    for (name, source) in [
981        ("GH_TOKEN", GithubTokenSource::GhTokenEnv),
982        ("GITHUB_TOKEN", GithubTokenSource::GithubTokenEnv),
983    ] {
984        if let Ok(token) = std::env::var(name)
985            && let Some(token) = usable_github_token(&token)
986        {
987            return Some(GithubCredential {
988                token: token.to_owned(),
989                source,
990            });
991        }
992    }
993    let mut command = tokio::process::Command::new("gh");
994    command.args(["auth", "token", "--hostname", "github.com"]);
995    let output = match mj_core::subprocess::run_bounded(
996        &mut command,
997        64 * 1024,
998        GH_AUTH_TOKEN_TIMEOUT,
999    )
1000    .await
1001    {
1002        Ok(output) => output,
1003        Err(error) => {
1004            tracing::debug!(
1005                error = format!("{error:#}"),
1006                "could not query the GitHub CLI for a token"
1007            );
1008            return None;
1009        }
1010    };
1011    if !output.status.success() {
1012        tracing::debug!(status = ?output.status, "GitHub CLI did not return an authenticated token");
1013        return None;
1014    }
1015    let token = match std::str::from_utf8(&output.stdout) {
1016        Ok(token) => token,
1017        Err(error) => {
1018            tracing::debug!(%error, "GitHub CLI returned a non-UTF-8 token");
1019            return None;
1020        }
1021    };
1022    let Some(token) = usable_github_token(token) else {
1023        tracing::debug!("GitHub CLI returned an empty or invalid token");
1024        return None;
1025    };
1026    Some(GithubCredential {
1027        token: token.to_owned(),
1028        source: GithubTokenSource::GhCli,
1029    })
1030}
1031
1032/// A token travels in an HTTP header and a git credential line, so only
1033/// visible ASCII qualifies; a stray control or non-ASCII byte is no token.
1034fn usable_github_token(token: &str) -> Option<&str> {
1035    let token = token.trim();
1036    (!token.is_empty() && token.bytes().all(|byte| byte.is_ascii_graphic())).then_some(token)
1037}
1038
1039pub(super) fn configure_github_token_environment(target: &mut targets::TargetTemplate) -> bool {
1040    let container = match target {
1041        targets::TargetTemplate::LocalPodman(container)
1042        | targets::TargetTemplate::LocalDocker(container)
1043        | targets::TargetTemplate::AppleContainer(container)
1044        | targets::TargetTemplate::SshPodman { container, .. }
1045        | targets::TargetTemplate::SshDocker { container, .. } => container,
1046        targets::TargetTemplate::LocalBare
1047        | targets::TargetTemplate::AwsEc2(_)
1048        | targets::TargetTemplate::SshBare { .. } => return false,
1049    };
1050    container
1051        .extra_run_args
1052        .extend(["--env".to_owned(), "GH_TOKEN".to_owned()]);
1053    true
1054}
1055
1056pub(super) fn use_github_https_urls(bundle: &mut targets::ProjectBundleSpec) {
1057    for repository in &mut bundle.repositories {
1058        for source in repository
1059            .url
1060            .iter_mut()
1061            .chain(repository.push_urls.iter_mut())
1062        {
1063            if let Some(github) = crate::setup::github_repository_from_origin(source) {
1064                *source = format!(
1065                    "https://github.com/{}/{}.git",
1066                    github.owner, github.repository
1067                );
1068            }
1069        }
1070    }
1071}
1072
1073fn backend_container(
1074    container: &mj_core::config::ContainerTemplate,
1075    allocation: Option<&SessionResourceAllocation>,
1076    overrides: ContainerOverrides<'_>,
1077) -> ContainerTemplate {
1078    let mut extra_run_args = Vec::new();
1079    if let Some(platform) = &container.platform {
1080        extra_run_args.push(format!("--platform={platform}"));
1081    }
1082    let (cpus, memory) = match allocation {
1083        Some(SessionResourceAllocation::Container { cpus, memory_bytes }) => {
1084            (Some(cpus.to_string()), Some(memory_bytes.to_string()))
1085        }
1086        _ => (container.cpus.clone(), container.memory.clone()),
1087    };
1088    // The session's own overrides are the last word on size.
1089    let cpus = overrides.cpus.map(str::to_owned).or(cpus);
1090    let memory = overrides.memory.map(str::to_owned).or(memory);
1091    if let Some(cpus) = cpus {
1092        extra_run_args.push(format!("--cpus={cpus}"));
1093    }
1094    if let Some(memory) = memory {
1095        extra_run_args.push(format!("--memory={memory}"));
1096    }
1097    for (key, value) in &container.environment {
1098        extra_run_args.extend(["--env".to_string(), format!("{key}={value}")]);
1099    }
1100    ContainerTemplate {
1101        image: container.image.clone(),
1102        pull_policy: container.pull_policy,
1103        extra_run_args,
1104        workspace_storage: targets::PodmanWorkspaceStorage::ContainerLayer,
1105        build_cache: container.build_cache.clone(),
1106    }
1107}
1108
1109pub(crate) fn validate_resource_allocation(
1110    target_kind: impl Into<ResourceAllocationKind>,
1111    allocation: Option<&SessionResourceAllocation>,
1112) -> Result<()> {
1113    if let Some(allocation) = allocation {
1114        allocation.validate()?;
1115    }
1116    match (target_kind.into(), allocation) {
1117        (_, None)
1118        | (ResourceAllocationKind::Container, Some(SessionResourceAllocation::Container { .. }))
1119        | (ResourceAllocationKind::AwsEc2, Some(SessionResourceAllocation::AwsEc2 { .. })) => {
1120            Ok(())
1121        }
1122        (ResourceAllocationKind::Fixed, Some(_)) => {
1123            bail!(mj_core::state::BARE_TARGET_FIXED_RESOURCES)
1124        }
1125        _ => bail!("resource allocation does not match the selected target kind"),
1126    }
1127}
1128
1129/// How long a freshly launched EC2 instance may take to accept SSH.
1130const AWS_SSH_READY_TIMEOUT: Duration = Duration::from_secs(300);
1131
1132const AWS_SSH_READY_RETRY_DELAY: Duration = Duration::from_secs(3);
1133
1134/// Poll a remote host until it accepts SSH, or until the deadline passes.
1135///
1136/// `now` and `sleep` are injected so tests can drive the deadline without
1137/// waiting in real time.
1138fn wait_for_ssh_ready(
1139    executor: &impl CommandExecutor,
1140    probe: &CommandSpec,
1141    timeout: Duration,
1142    mut now: impl FnMut() -> Instant,
1143    mut sleep: impl FnMut(Duration),
1144) -> Result<()> {
1145    let started = now();
1146    loop {
1147        if executor.cancellation_requested() {
1148            bail!("cancelled while waiting for SSH on the new instance");
1149        }
1150        let failure = match executor.execute(probe) {
1151            Ok(output) if output.status == 0 => return Ok(()),
1152            Ok(output) => String::from_utf8_lossy(&output.stderr).trim().to_string(),
1153            Err(error) => error.to_string(),
1154        };
1155        if now().duration_since(started) >= timeout {
1156            bail!(
1157                "{} timed out after {}s: {}",
1158                probe.purpose,
1159                timeout.as_secs(),
1160                if failure.is_empty() {
1161                    "the SSH probe reported no error output"
1162                } else {
1163                    failure.as_str()
1164                }
1165            );
1166        }
1167        sleep(AWS_SSH_READY_RETRY_DELAY);
1168    }
1169}
1170
1171pub(super) fn locator_after_provision_named(
1172    canonical: &TargetTemplate,
1173    backend: &targets::TargetTemplate,
1174    session_id: &str,
1175    first_output: Option<&CommandOutput>,
1176    executor: &(impl CommandExecutor + Sync),
1177    name: &str,
1178) -> Result<TargetLocator> {
1179    let generated = name.to_owned();
1180
1181    Ok(match canonical {
1182        TargetTemplate::LocalBare => TargetLocator::LocalBare {
1183            worker_root: data_dir().join("workers").join(session_id),
1184        },
1185        TargetTemplate::LocalPodman { .. } => {
1186            let targets::TargetTemplate::LocalPodman(container) = backend else {
1187                bail!("session locator/template mismatch")
1188            };
1189            TargetLocator::LocalPodman {
1190                borrowed_from: None,
1191                container_id: generated,
1192                workspace_storage: PodmanWorkspaceLocator::from(
1193                    targets::podman_workspace_locator_named(container, name)?,
1194                ),
1195            }
1196        }
1197        TargetTemplate::LocalDocker { .. } => TargetLocator::LocalDocker {
1198            borrowed_from: None,
1199            container_id: generated,
1200        },
1201        TargetTemplate::AppleContainer { .. } => TargetLocator::AppleContainer {
1202            borrowed_from: None,
1203            container_id: generated,
1204        },
1205        TargetTemplate::SshBare { ssh, .. } => TargetLocator::SshBare {
1206            host: ssh.host.clone(),
1207            workspace: PathBuf::from(targets::workspace_for(backend, session_id)?),
1208            worker_id: None,
1209        },
1210        TargetTemplate::SshPodman { ssh, .. } => {
1211            let targets::TargetTemplate::SshPodman { container, .. } = backend else {
1212                bail!("session locator/template mismatch")
1213            };
1214            TargetLocator::SshPodman {
1215                borrowed_from: None,
1216                host: ssh.host.clone(),
1217                container_id: generated,
1218                workspace_storage: PodmanWorkspaceLocator::from(
1219                    targets::podman_workspace_locator_named(container, name)?,
1220                ),
1221            }
1222        }
1223        TargetTemplate::SshDocker { ssh, .. } => TargetLocator::SshDocker {
1224            borrowed_from: None,
1225            host: ssh.host.clone(),
1226            container_id: generated,
1227        },
1228        TargetTemplate::AwsEc2 { .. } => {
1229            let output = first_output.context("AWS launch produced no output")?;
1230            let json: serde_json::Value = serde_json::from_slice(&output.stdout)
1231                .context("parse aws ec2 run-instances response")?;
1232            let instance_id = json
1233                .pointer("/Instances/0/InstanceId")
1234                .and_then(serde_json::Value::as_str)
1235                .context("AWS response omitted instance ID")?
1236                .to_string();
1237            return ec2_locator_after_launch(canonical, instance_id, executor);
1238        }
1239    })
1240}
1241
1242/// Resolve a created instance. Callers persist its ID before these long waits.
1243pub(super) fn ec2_locator_after_launch(
1244    canonical: &TargetTemplate,
1245    instance_id: String,
1246    executor: &(impl CommandExecutor + Sync),
1247) -> Result<TargetLocator> {
1248    let TargetTemplate::AwsEc2 {
1249        aws_profile,
1250        region,
1251        ssh_user,
1252        address_source,
1253        identity_file,
1254        ssh_args,
1255        ..
1256    } = canonical
1257    else {
1258        bail!("EC2 locator requires an EC2 target");
1259    };
1260    let profile = aws_profile.clone().unwrap_or_else(|| "default".into());
1261    execute_checked(
1262        executor,
1263        CommandSpec::new(
1264            "aws",
1265            [
1266                "--profile".into(),
1267                profile.clone(),
1268                "--region".into(),
1269                region.clone(),
1270                "ec2".into(),
1271                "wait".into(),
1272                "instance-running".into(),
1273                "--instance-ids".into(),
1274                instance_id.clone(),
1275            ],
1276        )
1277        .purpose("wait for EC2 session instance to run")
1278        .stage(ProvisionStage::Booting),
1279    )?;
1280    let field = match address_source {
1281        AwsAddressSource::PublicDns => "PublicDnsName",
1282        AwsAddressSource::PublicIp => "PublicIpAddress",
1283        AwsAddressSource::PrivateDns => "PrivateDnsName",
1284        AwsAddressSource::PrivateIp => "PrivateIpAddress",
1285    };
1286    let address = execute_checked(
1287        executor,
1288        CommandSpec::new(
1289            "aws",
1290            [
1291                "--profile".into(),
1292                profile.clone(),
1293                "--region".into(),
1294                region.clone(),
1295                "ec2".into(),
1296                "describe-instances".into(),
1297                "--instance-ids".into(),
1298                instance_id.clone(),
1299                "--query".into(),
1300                format!("Reservations[0].Instances[0].{field}"),
1301                "--output".into(),
1302                "text".into(),
1303            ],
1304        )
1305        .purpose("resolve EC2 session address")
1306        .stage(ProvisionStage::Booting),
1307    )?;
1308    let address = String::from_utf8(address.stdout)
1309        .context("AWS address was not UTF-8")?
1310        .trim()
1311        .to_string();
1312    if address.is_empty() || address == "None" {
1313        bail!("AWS instance {instance_id} has no configured address");
1314    }
1315    let ssh = SshTarget {
1316        destination: format!("{ssh_user}@{address}"),
1317        ssh_args: targets::ssh_args_with_identity(ssh_args, identity_file.as_deref()),
1318    };
1319    wait_for_ssh_ready(
1320        executor,
1321        &crate::targets::ssh_command(&ssh, ["true"])
1322            .purpose("wait for EC2 SSH availability")
1323            .stage(ProvisionStage::Booting),
1324        AWS_SSH_READY_TIMEOUT,
1325        Instant::now,
1326        std::thread::sleep,
1327    )?;
1328    Ok(TargetLocator::AwsEc2 {
1329        instance_id,
1330        address: Some(address),
1331    })
1332}
1333
1334/// The execution-plan locator for a session's stored target.
1335///
1336/// The mapping itself lives in `mj_core::targets`; this only pairs the stored
1337/// locator with the target template the session was created against.
1338pub(crate) fn backend_locator(
1339    locator: &TargetLocator,
1340    session: &SessionRecord,
1341    config: &Config,
1342) -> Result<targets::TargetLocator> {
1343    let runtime = if session.target_runtime.is_some() || targets::locator_needs_connection(locator)
1344    {
1345        Some(session.target_runtime_settings(config)?)
1346    } else {
1347        None
1348    };
1349    Ok(targets::TargetLocator::try_from(targets::RecordedTarget {
1350        locator,
1351        runtime: runtime.as_deref(),
1352        session_id: &session.id,
1353    })?)
1354}
1355
1356#[cfg(test)]
1357mod tests;