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, ResourceAllocationKind, SessionRecord, SessionResourceAllocation,
13    TargetLocator, 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    /// The directories the live sessions on one target write to.
274    fn session_storage_paths(&self, target_id: &str) -> Vec<String> {
275        self.state
276            .sessions
277            .values()
278            .filter(|session| session.target_template_id == target_id && session.state.is_active())
279            .filter_map(|session| {
280                let paths = mj_core::targets::storage::session_storage_paths(
281                    session.target.as_ref()?,
282                    &session.id,
283                    session.project_directory.as_deref(),
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                        probe_error,
354                    });
355                }
356            }
357        }
358        if !local_ids.is_empty() {
359            // CPU and memory come from sysinfo; the one probe measures storage.
360            let mut paths = Vec::new();
361            for target_id in &local_ids {
362                if let Some(template) = self.config.targets.get(target_id) {
363                    push_unique(&mut paths, local_storage_paths(template));
364                }
365                push_unique(&mut paths, self.session_storage_paths(target_id));
366            }
367            let mut probe = CommandSpec::new(
368                "sh",
369                [
370                    "-c",
371                    mj_core::targets::storage::STORAGE_PROBE_SCRIPT,
372                    "mj-storage",
373                ],
374            )
375            .purpose("measure local free space");
376            probe.args.extend(paths);
377            targets.push(DeploymentCapacityTarget {
378                id: "local".into(),
379                host: "local".into(),
380                target_ids: local_ids,
381                kind: DeploymentCapacityKind::Host,
382                local: true,
383                probes: vec![probe],
384                probe_error: None,
385            });
386        }
387        targets.extend(
388            ssh_hosts
389                .into_iter()
390                .map(
391                    |(host, (target_ids, connections))| DeploymentCapacityTarget {
392                        id: format!("ssh:{host}"),
393                        host,
394                        target_ids,
395                        kind: DeploymentCapacityKind::Host,
396                        local: false,
397                        probes: connections
398                            .iter()
399                            .map(|(ssh, paths)| targets::ssh_host_capacity_command(ssh, paths))
400                            .collect(),
401                        probe_error: None,
402                    },
403                ),
404        );
405        targets.sort_by(|left, right| left.id.cmp(&right.id));
406        targets
407    }
408
409    /// The full check behind the Targets pane's Test action.
410    pub fn test_target(&self, target_id: &str, executor: &impl CommandExecutor) -> Result<()> {
411        verify_target(
412            self.configured_target(target_id)?,
413            executor,
414            TargetCheck::BeforeLaunch,
415        )
416    }
417
418    /// The check a session wizard runs before it offers a target: the same
419    /// one a launch runs, worded for a launch that has not happened yet.
420    pub fn check_target_readiness(
421        &self,
422        target_id: &str,
423        executor: &impl CommandExecutor,
424    ) -> Result<()> {
425        preflight_target(
426            self.configured_target(target_id)?,
427            executor,
428            TargetCheck::BeforeLaunch,
429        )
430    }
431
432    fn configured_target(&self, target_id: &str) -> Result<&TargetTemplate> {
433        self.config
434            .targets
435            .get(target_id)
436            .with_context(|| format!("unknown target template {target_id:?}"))
437    }
438}
439
440/// When a target is checked, which decides how its refusal ends.
441#[derive(Debug, Clone, Copy, PartialEq, Eq)]
442pub(super) enum TargetCheck {
443    /// Before anything is launched: the session wizard's target row, the
444    /// Targets pane.
445    BeforeLaunch,
446    /// Inside a launch, resume, or move, whose failure the launch-failure
447    /// dialog reports with its Retry launch button.
448    Launch,
449}
450
451impl TargetCheck {
452    /// The clause that points at the failure dialog's Retry launch. Before a
453    /// launch there is no such dialog to point at (launch finding R6-3).
454    const fn then_retry(self) -> &'static str {
455        match self {
456            Self::BeforeLaunch => "",
457            Self::Launch => ", then Retry launch",
458        }
459    }
460}
461
462/// The directories Mjolnir writes to on an SSH host for one target, relative
463/// to the SSH user's home where they live there: worker roots, the upload
464/// staging and binary caches, and the workspace prefix or the container
465/// engine's storage. Each is measured at its nearest existing ancestor.
466pub(crate) fn ssh_storage_paths(template: &TargetTemplate) -> Vec<String> {
467    use mj_core::targets::storage::{
468        ContainerStorage, DEFAULT_BUILD_CACHE_DIRECTORY, REMOTE_CACHE_DIRECTORY,
469        REMOTE_PROFILES_DIRECTORY, REMOTE_WORKERS_DIRECTORY, TEMPORARY_DIRECTORY,
470    };
471    let mut paths = vec![
472        REMOTE_CACHE_DIRECTORY.to_owned(),
473        DEFAULT_BUILD_CACHE_DIRECTORY.to_owned(),
474        TEMPORARY_DIRECTORY.to_owned(),
475    ];
476    match template {
477        TargetTemplate::SshBare {
478            workspace_prefix, ..
479        } => {
480            paths.push(REMOTE_WORKERS_DIRECTORY.to_owned());
481            paths.push(REMOTE_PROFILES_DIRECTORY.to_owned());
482            paths.push(workspace_prefix.to_string_lossy().into_owned());
483        }
484        TargetTemplate::SshPodman { .. } => paths.push(ContainerStorage::Podman.path().to_owned()),
485        TargetTemplate::SshDocker { .. } => paths.push(ContainerStorage::Docker.path().to_owned()),
486        _ => {}
487    }
488    paths.extend(build_cache_directory(template));
489    paths
490}
491
492/// A target's configured mbx cache directory, when it names one.
493fn build_cache_directory(template: &TargetTemplate) -> Option<String> {
494    template
495        .container()?
496        .build_cache
497        .as_ref()?
498        .directory
499        .as_ref()
500        .map(|directory| directory.to_string_lossy().into_owned())
501}
502
503/// The paths the local probe measures for one local target.
504fn local_storage_paths(template: &TargetTemplate) -> Vec<String> {
505    use mj_core::targets::storage::{
506        ContainerStorage, DEFAULT_BUILD_CACHE_DIRECTORY, TEMPORARY_DIRECTORY, local_home_path,
507    };
508    let mut paths = vec![
509        data_dir().to_string_lossy().into_owned(),
510        TEMPORARY_DIRECTORY.to_owned(),
511        local_home_path(DEFAULT_BUILD_CACHE_DIRECTORY),
512    ];
513    match template {
514        TargetTemplate::LocalPodman { .. } => {
515            paths.push(local_home_path(ContainerStorage::Podman.path()));
516        }
517        TargetTemplate::LocalDocker { .. } => {
518            paths.push(ContainerStorage::Docker.path().to_owned());
519        }
520        _ => {}
521    }
522    paths.extend(build_cache_directory(template));
523    paths
524}
525
526fn push_unique(paths: &mut Vec<String>, more: impl IntoIterator<Item = String>) {
527    for path in more {
528        if !path.is_empty() && !paths.contains(&path) {
529            paths.push(path);
530        }
531    }
532}
533
534/// Check a target before a launch, resume, or move uses it.
535///
536/// An SSH host's container runtime is verified by `mj setup`, `mj doctor`,
537/// and the Targets pane's Test action; here it is assumed to still be as
538/// they left it, and only the host's reachability is checked. That probe
539/// joins an open shared connection, so it usually costs one SSH channel.
540pub(super) fn preflight_target(
541    template: &TargetTemplate,
542    executor: &impl CommandExecutor,
543    check: TargetCheck,
544) -> Result<()> {
545    match template {
546        TargetTemplate::SshBare { ssh, .. }
547        | TargetTemplate::SshPodman { ssh, .. }
548        | TargetTemplate::SshDocker { ssh, .. } => {
549            verify_ssh_connectivity(&SshTarget::from(ssh), executor)
550        }
551        _ => verify_target(template, executor, check),
552    }
553}
554
555/// Whether a local container engine can run sessions, as the launch
556/// preflight and the dashboard's Targets pane judge it.
557#[derive(Debug, Clone, Copy, PartialEq, Eq)]
558pub(crate) enum LocalEngineReadiness {
559    Ready,
560    /// The engine's command is not on this host.
561    NotInstalled,
562    /// The command is there, but the engine did not answer its check.
563    NotReady,
564}
565
566/// Run the launch preflight's engine check for a local container target
567/// kind (`local-podman`, `local-docker`, or `apple-container`).
568///
569/// Answers `None` for any other kind: their readiness is not a local engine's.
570pub(crate) fn local_engine_readiness(
571    kind: &str,
572    executor: &impl CommandExecutor,
573) -> Option<LocalEngineReadiness> {
574    let result = match kind {
575        "local-podman" => targets::verify_local_podman(executor).map(|_| ()),
576        "local-docker" => targets::verify_local_docker(executor).map(|_| ()),
577        // The same command `verify_target` runs, without its sentence, which
578        // flattens the cause this needs to tell a missing command apart.
579        "apple-container" => executor
580            .execute(
581                &CommandSpec::new("container", ["system", "status"])
582                    .purpose("preflight Apple container runtime")
583                    .stage(ProvisionStage::Provisioning),
584            )
585            .and_then(|output| {
586                ensure!(output.status == 0, "container system status failed");
587                Ok(())
588            }),
589        _ => return None,
590    };
591    Some(match result {
592        Ok(()) => LocalEngineReadiness::Ready,
593        Err(error) if is_missing_command(&error) => LocalEngineReadiness::NotInstalled,
594        Err(_) => LocalEngineReadiness::NotReady,
595    })
596}
597
598/// Whether a command failed because its program is not installed.
599fn is_missing_command(error: &anyhow::Error) -> bool {
600    error.chain().any(|cause| {
601        cause
602            .downcast_ref::<std::io::Error>()
603            .is_some_and(|io| io.kind() == std::io::ErrorKind::NotFound)
604    })
605}
606
607/// Check that a target's host and runtime can run sessions.
608pub(super) fn verify_target(
609    template: &TargetTemplate,
610    executor: &impl CommandExecutor,
611    check: TargetCheck,
612) -> Result<()> {
613    let then_retry = check.then_retry();
614    match template {
615        TargetTemplate::LocalPodman { .. } => targets::verify_local_podman(executor)
616            .map(|_| ())
617            .map_err(|error| {
618                anyhow::anyhow!(
619                    "local Podman is not ready. Fix the problem below{then_retry}: {error:#}"
620                )
621            }),
622        TargetTemplate::LocalDocker { .. } => targets::verify_local_docker(executor)
623            .map(|_| ())
624            .map_err(
625                |error| match error.downcast_ref::<targets::DockerUnavailable>() {
626                    // Leads with the sentence the launch options use, so a
627                    // missing Docker reads "not installed" in the wizard's
628                    // row too (launch finding R5-3).
629                    Some(problem) => anyhow::anyhow!(
630                        "{problem} {}",
631                        match check {
632                            TargetCheck::BeforeLaunch => problem.remedy(),
633                            TargetCheck::Launch => problem.launch_remedy(),
634                        }
635                    ),
636                    None => anyhow::anyhow!(
637                        "local Docker is not ready. Start Docker or fix the problem below{then_retry}: {error:#}"
638                    ),
639                },
640            ),
641        TargetTemplate::SshPodman { ssh, .. } => {
642            let ssh = SshTarget::from(ssh);
643            targets::verify_ssh_podman(&ssh, executor)
644                .map(|preflight| {
645                    for warning in preflight.warnings {
646                        executor.notify_notice(&warning.notice());
647                    }
648                })
649                .map_err(|error| {
650                    anyhow::anyhow!(
651                        "remote Podman is not ready on {}. Fix the problem below{then_retry}: {error:#}",
652                        ssh.destination
653                    )
654                })
655        }
656        TargetTemplate::SshDocker { ssh, .. } => {
657            let ssh = SshTarget::from(ssh);
658            targets::verify_ssh_docker(&ssh, executor)
659                .map(|_| ())
660                .map_err(|error| {
661                    anyhow::anyhow!(
662                        "remote Docker preflight failed for {}. Fix the problem below{then_retry}: {error:#}",
663                        ssh.destination
664                    )
665                })
666        }
667        TargetTemplate::AppleContainer { .. } => {
668            let command = CommandSpec::new("container", ["system", "status"])
669                .purpose("preflight Apple container runtime")
670                .stage(ProvisionStage::Provisioning);
671            let output = executor.execute(&command).map_err(|error| {
672                anyhow::anyhow!(
673                    "Apple container is not ready. Fix the problem below{then_retry}: {error}"
674                )
675            })?;
676            if output.status != 0 {
677                bail!(
678                    "Apple container is not ready. Start the runtime with `container system start`{then_retry}: container system status exited {}: {}",
679                    output.status,
680                    [
681                        String::from_utf8_lossy(&output.stdout).trim(),
682                        String::from_utf8_lossy(&output.stderr).trim(),
683                    ]
684                    .into_iter()
685                    .filter(|message| !message.is_empty())
686                    .collect::<Vec<_>>()
687                    .join("\n")
688                );
689            }
690            Ok(())
691        }
692        TargetTemplate::SshBare { ssh, .. } => {
693            verify_ssh_connectivity(&SshTarget::from(ssh), executor)
694        }
695        TargetTemplate::AwsEc2 {
696            aws_profile,
697            region,
698            launch_template,
699            launch_template_version,
700            ..
701        } => {
702            let mut identity_args = vec!["sts".into(), "get-caller-identity".into()];
703            if let Some(profile) = aws_profile {
704                identity_args.extend(["--profile".into(), profile.clone()]);
705            }
706            let identity = CommandSpec::new("aws", identity_args)
707                .purpose("verify AWS credentials")
708                .stage(ProvisionStage::Provisioning);
709            let output = executor.execute(&identity)?;
710            ensure!(
711                output.status == 0,
712                "AWS credential test failed with status {}: {}",
713                output.status,
714                String::from_utf8_lossy(&output.stderr).trim()
715            );
716
717            let mut launch_args = vec![
718                "ec2".into(),
719                "describe-launch-template-versions".into(),
720                "--region".into(),
721                region.clone(),
722                if launch_template.starts_with("lt-") { "--launch-template-id" } else { "--launch-template-name" }.into(),
723                launch_template.clone(),
724                "--versions".into(),
725                launch_template_version
726                    .clone()
727                    .unwrap_or_else(|| "$Default".into()),
728            ];
729            if let Some(profile) = aws_profile {
730                launch_args.extend(["--profile".into(), profile.clone()]);
731            }
732            let launch = CommandSpec::new("aws", launch_args)
733                .purpose("verify AWS launch template")
734                .stage(ProvisionStage::Provisioning);
735            let output = executor.execute(&launch)?;
736            ensure!(
737                output.status == 0,
738                "AWS launch-template test failed with status {}: {}",
739                output.status,
740                String::from_utf8_lossy(&output.stderr).trim()
741            );
742            Ok(())
743        }
744        TargetTemplate::LocalBare => Ok(()),
745    }
746}
747
748fn verify_ssh_connectivity(ssh: &SshTarget, executor: &impl CommandExecutor) -> Result<()> {
749    let output = executor.execute(&targets::ssh_connectivity_probe(ssh))?;
750    ensure!(
751        output.status == 0,
752        "SSH connectivity test failed for {} with status {}: {}",
753        ssh.destination,
754        output.status,
755        String::from_utf8_lossy(&output.stderr).trim()
756    );
757    Ok(())
758}
759
760#[cfg(test)]
761pub(super) fn backend_bundle(
762    bundle: &ProjectBundle,
763    executor: &impl CommandExecutor,
764) -> Result<ProjectBundleSpec> {
765    backend_bundle_with_sources(bundle, None, executor)
766}
767
768pub(super) fn backend_session_bundle(
769    session: &SessionRecord,
770    config: &mj_core::config::Config,
771    executor: &impl CommandExecutor,
772) -> Result<ProjectBundleSpec> {
773    backend_bundle_with_sources(
774        session
775            .project_bundle(config)
776            .context("session bundle is missing")?,
777        session
778            .project
779            .as_ref()
780            .map(|project| &project.network_sources),
781        executor,
782    )
783}
784
785fn backend_bundle_with_sources(
786    bundle: &ProjectBundle,
787    sources: Option<&std::collections::BTreeMap<String, mj_core::remote_git::NetworkGitSource>>,
788    executor: &impl CommandExecutor,
789) -> Result<ProjectBundleSpec> {
790    let primary = bundle.primary().context("bundle primary is missing")?;
791    Ok(ProjectBundleSpec {
792        primary: primary.destination.to_string_lossy().into_owned(),
793        repositories: bundle
794            .repositories
795            .iter()
796            .map(|repository| {
797                let source = match sources.and_then(|sources| sources.get(&repository.id)) {
798                    Some(source) => source.clone(),
799                    None => mj_core::remote_git::resolve_repository(repository, executor)
800                        .with_context(|| format!("repository {:?}", repository.id))?,
801                };
802                Ok(RepositorySpec {
803                    url: Some(source.fetch_url),
804                    push_urls: source.push_urls,
805                    destination: repository.destination.to_string_lossy().into_owned(),
806                    git_ref: None,
807                    reference: None,
808                })
809            })
810            .collect::<Result<Vec<_>>>()?,
811    })
812}
813
814/// Per-session container size overrides. They win over both the target
815/// template's values and any recorded resource allocation, and they are read
816/// only while a container is being created.
817#[derive(Debug, Clone, Copy, Default)]
818pub(super) struct ContainerOverrides<'a> {
819    pub cpus: Option<&'a str>,
820    pub memory: Option<&'a str>,
821}
822
823impl<'a> ContainerOverrides<'a> {
824    pub(super) fn for_session(session: &'a SessionRecord) -> Self {
825        Self {
826            cpus: session.container_cpus.as_deref(),
827            memory: session.container_memory.as_deref(),
828        }
829    }
830}
831
832pub(super) fn backend_target(
833    template: &TargetTemplate,
834    allocation: Option<&SessionResourceAllocation>,
835    overrides: ContainerOverrides<'_>,
836) -> Result<targets::TargetTemplate> {
837    Ok(match template {
838        TargetTemplate::LocalBare => targets::TargetTemplate::LocalBare,
839        TargetTemplate::LocalPodman { container } => {
840            let mut backend = backend_container(container, allocation, overrides);
841            backend.workspace_storage = (&container.workspace_storage).into();
842            targets::TargetTemplate::LocalPodman(backend)
843        }
844        TargetTemplate::LocalDocker { container } => targets::TargetTemplate::LocalDocker(
845            backend_container(container, allocation, overrides),
846        ),
847        TargetTemplate::AppleContainer { container } => targets::TargetTemplate::AppleContainer(
848            backend_container(container, allocation, overrides),
849        ),
850        TargetTemplate::AwsEc2 {
851            aws_profile,
852            region,
853            launch_template,
854            launch_template_version,
855            ssh_user,
856            identity_file,
857            ssh_args,
858            ..
859        } => targets::TargetTemplate::AwsEc2(AwsTemplate {
860            profile: aws_profile.clone().unwrap_or_else(|| "default".into()),
861            region: region.clone(),
862            launch_template: launch_template.clone(),
863            launch_template_version: launch_template_version.clone(),
864            instance_type: match allocation {
865                Some(SessionResourceAllocation::AwsEc2 { instance_type, .. }) => {
866                    Some(instance_type.clone())
867                }
868                _ => None,
869            },
870            // The address is filled after describe-instances.
871            ssh: SshTarget {
872                destination: format!("{ssh_user}@pending.invalid"),
873                ssh_args: targets::ssh_args_with_identity(ssh_args, identity_file.as_deref()),
874            },
875        }),
876        TargetTemplate::SshBare {
877            ssh,
878            workspace_prefix,
879            ..
880        } => targets::TargetTemplate::SshBare {
881            ssh: SshTarget::from(ssh),
882            workspace_prefix: workspace_prefix.to_string_lossy().into_owned(),
883        },
884        TargetTemplate::SshPodman { ssh, container, .. } => {
885            let mut backend = backend_container(container, allocation, overrides);
886            backend.workspace_storage = (&container.workspace_storage).into();
887            targets::TargetTemplate::SshPodman {
888                ssh: SshTarget::from(ssh),
889                container: backend,
890            }
891        }
892        TargetTemplate::SshDocker { ssh, container, .. } => targets::TargetTemplate::SshDocker {
893            ssh: SshTarget::from(ssh),
894            container: backend_container(container, allocation, overrides),
895        },
896    })
897}
898
899/// Every container image the daemon downloads in the background, once per
900/// (host, image, platform).
901///
902/// Every container target is covered, including Apple's `container` engine.
903/// A `never` policy is the one opt-out. The rest differ only in when they
904/// download: `always` and `newer` pull on every refresh, while the others
905/// pull only when the host has no copy of the image.
906///
907/// Several targets often share one image on one host, and that needs one
908/// download. When two such targets disagree about when to pull, the merged
909/// entry takes the more eager of the two.
910pub fn image_refresh_plan(config: &Config) -> Vec<ImageRefresh> {
911    let mut plan: Vec<ImageRefresh> = Vec::new();
912    for target in config.targets.values() {
913        let Some((host, container)) = target.image_host() else {
914            continue;
915        };
916        let Some(refresh) = targets::image_refresh(
917            host,
918            &container.image,
919            container.platform.as_deref(),
920            container.pull_policy,
921        ) else {
922            continue;
923        };
924        // Commands are decided by the host, the image, and the platform alone,
925        // so those three identify the duplicates worth collapsing.
926        if let Some(existing) = plan.iter_mut().find(|entry| {
927            entry.host == refresh.host
928                && entry.image == refresh.image
929                && entry.platform == refresh.platform
930        }) {
931            existing.when = existing.when.max(refresh.when);
932            continue;
933        }
934        plan.push(refresh);
935    }
936    plan
937}
938
939pub(crate) fn controller_github_token() -> Option<String> {
940    for name in ["GH_TOKEN", "GITHUB_TOKEN"] {
941        if let Ok(token) = std::env::var(name)
942            && let Some(token) = usable_github_token(&token)
943        {
944            return Some(token.to_owned());
945        }
946    }
947    let output = match Command::new("gh")
948        .args(["auth", "token", "--hostname", "github.com"])
949        .stdin(Stdio::null())
950        .stderr(Stdio::null())
951        .output()
952    {
953        Ok(output) => output,
954        Err(error) => {
955            tracing::debug!(%error, "could not query the GitHub CLI for a token");
956            return None;
957        }
958    };
959    if !output.status.success() {
960        tracing::debug!(status = ?output.status, "GitHub CLI did not return an authenticated token");
961        return None;
962    }
963    let token = match std::str::from_utf8(&output.stdout) {
964        Ok(token) => token,
965        Err(error) => {
966            tracing::debug!(%error, "GitHub CLI returned a non-UTF-8 token");
967            return None;
968        }
969    };
970    let Some(token) = usable_github_token(token) else {
971        tracing::debug!("GitHub CLI returned an empty or invalid token");
972        return None;
973    };
974    Some(token.to_owned())
975}
976
977fn usable_github_token(token: &str) -> Option<&str> {
978    let token = token.trim();
979    (!token.is_empty() && !token.chars().any(char::is_whitespace)).then_some(token)
980}
981
982pub(super) fn configure_github_token_environment(target: &mut targets::TargetTemplate) -> bool {
983    let container = match target {
984        targets::TargetTemplate::LocalPodman(container)
985        | targets::TargetTemplate::LocalDocker(container)
986        | targets::TargetTemplate::AppleContainer(container)
987        | targets::TargetTemplate::SshPodman { container, .. }
988        | targets::TargetTemplate::SshDocker { container, .. } => container,
989        targets::TargetTemplate::LocalBare
990        | targets::TargetTemplate::AwsEc2(_)
991        | targets::TargetTemplate::SshBare { .. } => return false,
992    };
993    container
994        .extra_run_args
995        .extend(["--env".to_owned(), "GH_TOKEN".to_owned()]);
996    true
997}
998
999pub(super) fn use_github_https_urls(bundle: &mut targets::ProjectBundleSpec) {
1000    for repository in &mut bundle.repositories {
1001        for source in repository
1002            .url
1003            .iter_mut()
1004            .chain(repository.push_urls.iter_mut())
1005        {
1006            if let Some(github) = crate::setup::github_repository_from_origin(source) {
1007                *source = format!(
1008                    "https://github.com/{}/{}.git",
1009                    github.owner, github.repository
1010                );
1011            }
1012        }
1013    }
1014}
1015
1016fn backend_container(
1017    container: &mj_core::config::ContainerTemplate,
1018    allocation: Option<&SessionResourceAllocation>,
1019    overrides: ContainerOverrides<'_>,
1020) -> ContainerTemplate {
1021    let mut extra_run_args = Vec::new();
1022    if let Some(platform) = &container.platform {
1023        extra_run_args.push(format!("--platform={platform}"));
1024    }
1025    let (cpus, memory) = match allocation {
1026        Some(SessionResourceAllocation::Container { cpus, memory_bytes }) => {
1027            (Some(cpus.to_string()), Some(memory_bytes.to_string()))
1028        }
1029        _ => (container.cpus.clone(), container.memory.clone()),
1030    };
1031    // The session's own overrides are the last word on size.
1032    let cpus = overrides.cpus.map(str::to_owned).or(cpus);
1033    let memory = overrides.memory.map(str::to_owned).or(memory);
1034    if let Some(cpus) = cpus {
1035        extra_run_args.push(format!("--cpus={cpus}"));
1036    }
1037    if let Some(memory) = memory {
1038        extra_run_args.push(format!("--memory={memory}"));
1039    }
1040    for (key, value) in &container.environment {
1041        extra_run_args.extend(["--env".to_string(), format!("{key}={value}")]);
1042    }
1043    ContainerTemplate {
1044        image: container.image.clone(),
1045        pull_policy: container.pull_policy,
1046        extra_run_args,
1047        workspace_storage: targets::PodmanWorkspaceStorage::ContainerLayer,
1048        build_cache: container.build_cache.clone(),
1049    }
1050}
1051
1052pub(crate) fn validate_resource_allocation(
1053    target_kind: impl Into<ResourceAllocationKind>,
1054    allocation: Option<&SessionResourceAllocation>,
1055) -> Result<()> {
1056    if let Some(allocation) = allocation {
1057        allocation.validate()?;
1058    }
1059    match (target_kind.into(), allocation) {
1060        (_, None)
1061        | (ResourceAllocationKind::Container, Some(SessionResourceAllocation::Container { .. }))
1062        | (ResourceAllocationKind::AwsEc2, Some(SessionResourceAllocation::AwsEc2 { .. })) => {
1063            Ok(())
1064        }
1065        (ResourceAllocationKind::Fixed, Some(_)) => {
1066            bail!(mj_core::state::BARE_TARGET_FIXED_RESOURCES)
1067        }
1068        _ => bail!("resource allocation does not match the selected target kind"),
1069    }
1070}
1071
1072/// How long a freshly launched EC2 instance may take to accept SSH.
1073const AWS_SSH_READY_TIMEOUT: Duration = Duration::from_secs(300);
1074
1075const AWS_SSH_READY_RETRY_DELAY: Duration = Duration::from_secs(3);
1076
1077/// Poll a remote host until it accepts SSH, or until the deadline passes.
1078///
1079/// `now` and `sleep` are injected so tests can drive the deadline without
1080/// waiting in real time.
1081fn wait_for_ssh_ready(
1082    executor: &impl CommandExecutor,
1083    probe: &CommandSpec,
1084    timeout: Duration,
1085    mut now: impl FnMut() -> Instant,
1086    mut sleep: impl FnMut(Duration),
1087) -> Result<()> {
1088    let started = now();
1089    loop {
1090        if executor.cancellation_requested() {
1091            bail!("cancelled while waiting for SSH on the new instance");
1092        }
1093        let failure = match executor.execute(probe) {
1094            Ok(output) if output.status == 0 => return Ok(()),
1095            Ok(output) => String::from_utf8_lossy(&output.stderr).trim().to_string(),
1096            Err(error) => error.to_string(),
1097        };
1098        if now().duration_since(started) >= timeout {
1099            bail!(
1100                "{} timed out after {}s: {}",
1101                probe.purpose,
1102                timeout.as_secs(),
1103                if failure.is_empty() {
1104                    "the SSH probe reported no error output"
1105                } else {
1106                    failure.as_str()
1107                }
1108            );
1109        }
1110        sleep(AWS_SSH_READY_RETRY_DELAY);
1111    }
1112}
1113
1114pub(super) fn locator_after_provision_named(
1115    canonical: &TargetTemplate,
1116    backend: &targets::TargetTemplate,
1117    session_id: &str,
1118    first_output: Option<&CommandOutput>,
1119    executor: &(impl CommandExecutor + Sync),
1120    name: &str,
1121) -> Result<TargetLocator> {
1122    let generated = name.to_owned();
1123
1124    Ok(match canonical {
1125        TargetTemplate::LocalBare => TargetLocator::LocalBare {
1126            worker_root: data_dir().join("workers").join(session_id),
1127        },
1128        TargetTemplate::LocalPodman { .. } => {
1129            let targets::TargetTemplate::LocalPodman(container) = backend else {
1130                bail!("session locator/template mismatch")
1131            };
1132            TargetLocator::LocalPodman {
1133                borrowed_from: None,
1134                container_id: generated,
1135                workspace_storage: PodmanWorkspaceLocator::from(
1136                    targets::podman_workspace_locator_named(container, name)?,
1137                ),
1138            }
1139        }
1140        TargetTemplate::LocalDocker { .. } => TargetLocator::LocalDocker {
1141            borrowed_from: None,
1142            container_id: generated,
1143        },
1144        TargetTemplate::AppleContainer { .. } => TargetLocator::AppleContainer {
1145            borrowed_from: None,
1146            container_id: generated,
1147        },
1148        TargetTemplate::SshBare { ssh, .. } => TargetLocator::SshBare {
1149            host: ssh.host.clone(),
1150            workspace: PathBuf::from(targets::workspace_for(backend, session_id)?),
1151            worker_id: None,
1152        },
1153        TargetTemplate::SshPodman { ssh, .. } => {
1154            let targets::TargetTemplate::SshPodman { container, .. } = backend else {
1155                bail!("session locator/template mismatch")
1156            };
1157            TargetLocator::SshPodman {
1158                borrowed_from: None,
1159                host: ssh.host.clone(),
1160                container_id: generated,
1161                workspace_storage: PodmanWorkspaceLocator::from(
1162                    targets::podman_workspace_locator_named(container, name)?,
1163                ),
1164            }
1165        }
1166        TargetTemplate::SshDocker { ssh, .. } => TargetLocator::SshDocker {
1167            borrowed_from: None,
1168            host: ssh.host.clone(),
1169            container_id: generated,
1170        },
1171        TargetTemplate::AwsEc2 { .. } => {
1172            let output = first_output.context("AWS launch produced no output")?;
1173            let json: serde_json::Value = serde_json::from_slice(&output.stdout)
1174                .context("parse aws ec2 run-instances response")?;
1175            let instance_id = json
1176                .pointer("/Instances/0/InstanceId")
1177                .and_then(serde_json::Value::as_str)
1178                .context("AWS response omitted instance ID")?
1179                .to_string();
1180            return ec2_locator_after_launch(canonical, instance_id, executor);
1181        }
1182    })
1183}
1184
1185/// Resolve a created instance. Callers persist its ID before these long waits.
1186pub(super) fn ec2_locator_after_launch(
1187    canonical: &TargetTemplate,
1188    instance_id: String,
1189    executor: &(impl CommandExecutor + Sync),
1190) -> Result<TargetLocator> {
1191    let TargetTemplate::AwsEc2 {
1192        aws_profile,
1193        region,
1194        ssh_user,
1195        address_source,
1196        identity_file,
1197        ssh_args,
1198        ..
1199    } = canonical
1200    else {
1201        bail!("EC2 locator requires an EC2 target");
1202    };
1203    let profile = aws_profile.clone().unwrap_or_else(|| "default".into());
1204    execute_checked(
1205        executor,
1206        CommandSpec::new(
1207            "aws",
1208            [
1209                "--profile".into(),
1210                profile.clone(),
1211                "--region".into(),
1212                region.clone(),
1213                "ec2".into(),
1214                "wait".into(),
1215                "instance-running".into(),
1216                "--instance-ids".into(),
1217                instance_id.clone(),
1218            ],
1219        )
1220        .purpose("wait for EC2 session instance to run")
1221        .stage(ProvisionStage::Booting),
1222    )?;
1223    let field = match address_source {
1224        AwsAddressSource::PublicDns => "PublicDnsName",
1225        AwsAddressSource::PublicIp => "PublicIpAddress",
1226        AwsAddressSource::PrivateDns => "PrivateDnsName",
1227        AwsAddressSource::PrivateIp => "PrivateIpAddress",
1228    };
1229    let address = execute_checked(
1230        executor,
1231        CommandSpec::new(
1232            "aws",
1233            [
1234                "--profile".into(),
1235                profile.clone(),
1236                "--region".into(),
1237                region.clone(),
1238                "ec2".into(),
1239                "describe-instances".into(),
1240                "--instance-ids".into(),
1241                instance_id.clone(),
1242                "--query".into(),
1243                format!("Reservations[0].Instances[0].{field}"),
1244                "--output".into(),
1245                "text".into(),
1246            ],
1247        )
1248        .purpose("resolve EC2 session address")
1249        .stage(ProvisionStage::Booting),
1250    )?;
1251    let address = String::from_utf8(address.stdout)
1252        .context("AWS address was not UTF-8")?
1253        .trim()
1254        .to_string();
1255    if address.is_empty() || address == "None" {
1256        bail!("AWS instance {instance_id} has no configured address");
1257    }
1258    let ssh = SshTarget {
1259        destination: format!("{ssh_user}@{address}"),
1260        ssh_args: targets::ssh_args_with_identity(ssh_args, identity_file.as_deref()),
1261    };
1262    wait_for_ssh_ready(
1263        executor,
1264        &crate::targets::ssh_command(&ssh, ["true"])
1265            .purpose("wait for EC2 SSH availability")
1266            .stage(ProvisionStage::Booting),
1267        AWS_SSH_READY_TIMEOUT,
1268        Instant::now,
1269        std::thread::sleep,
1270    )?;
1271    Ok(TargetLocator::AwsEc2 {
1272        instance_id,
1273        address: Some(address),
1274    })
1275}
1276
1277/// The execution-plan locator for a session's stored target.
1278///
1279/// The mapping itself lives in `mj_core::targets`; this only pairs the stored
1280/// locator with the target template the session was created against.
1281pub(crate) fn backend_locator(
1282    locator: &TargetLocator,
1283    session: &SessionRecord,
1284    config: &Config,
1285) -> Result<targets::TargetLocator> {
1286    let runtime = if session.target_runtime.is_some() || targets::locator_needs_connection(locator)
1287    {
1288        Some(session.target_runtime_settings(config)?)
1289    } else {
1290        None
1291    };
1292    Ok(targets::TargetLocator::try_from(targets::RecordedTarget {
1293        locator,
1294        runtime: runtime.as_deref(),
1295        session_id: &session.id,
1296    })?)
1297}
1298
1299#[cfg(test)]
1300mod tests;