1use 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 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 "not a git checkout".to_owned()
68 } else {
69 format!(
70 "unavailable: {}",
71 stderr.lines().next().unwrap_or("").trim()
72 )
73 }
74 };
75 Ok((launch.cwd, branch))
76 }
77
78 pub fn 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 let ahead_behind = run(
115 &["rev-list", "--left-right", "--count", "@{upstream}...HEAD"],
116 "count commits against upstream",
117 )?;
118 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 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 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 probe_error,
354 });
355 }
356 }
357 }
358 if !local_ids.is_empty() {
359 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 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 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
442pub(super) enum TargetCheck {
443 BeforeLaunch,
446 Launch,
449}
450
451impl TargetCheck {
452 const fn then_retry(self) -> &'static str {
455 match self {
456 Self::BeforeLaunch => "",
457 Self::Launch => ", then Retry launch",
458 }
459 }
460}
461
462pub(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
492fn 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
503fn 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
534pub(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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
558pub(crate) enum LocalEngineReadiness {
559 Ready,
560 NotInstalled,
562 NotReady,
564}
565
566pub(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 "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
598fn 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
607pub(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 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#[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 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
899pub 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 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 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
1072const AWS_SSH_READY_TIMEOUT: Duration = Duration::from_secs(300);
1074
1075const AWS_SSH_READY_RETRY_DELAY: Duration = Duration::from_secs(3);
1076
1077fn 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
1185pub(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
1277pub(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;