1use 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 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 "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 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 let ahead_behind = run(
114 &["rev-list", "--left-right", "--count", "@{upstream}...HEAD"],
115 "count commits against upstream",
116 )?;
117 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 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 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 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 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 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 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
437pub(super) enum TargetCheck {
438 BeforeLaunch,
441 Launch,
444}
445
446impl TargetCheck {
447 const fn then_retry(self) -> &'static str {
450 match self {
451 Self::BeforeLaunch => "",
452 Self::Launch => ", then Retry launch",
453 }
454 }
455}
456
457pub(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
487fn 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
498fn 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
531pub(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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
555pub(crate) enum LocalEngineReadiness {
556 Ready,
557 NotInstalled,
559 NotReady,
561}
562
563pub(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 "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
595fn 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
604pub(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 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#[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 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
896pub 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 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#[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
969const 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
1032fn 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 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
1129const AWS_SSH_READY_TIMEOUT: Duration = Duration::from_secs(300);
1131
1132const AWS_SSH_READY_RETRY_DELAY: Duration = Duration::from_secs(3);
1133
1134fn 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
1242pub(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
1334pub(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;