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, SessionRecord, SessionResourceAllocation, TargetLocator,
13 allocation_cpus,
14};
15
16use crate::targets::{
17 self, AwsTemplate, CommandExecutor, CommandOutput, CommandSpec, ContainerTemplate,
18 ImageRefresh, ProjectBundleSpec, ProvisionStage, RepositorySpec, SshTarget,
19};
20
21use super::{Controller, execute_checked};
22
23impl Controller {
24 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 pub(crate) fn session_owns_profile_home(&self, session_id: &str) -> Result<bool> {
280 let session = self
281 .state
282 .sessions
283 .get(session_id)
284 .with_context(|| format!("unknown session {session_id}"))?;
285 let profile = self
286 .config
287 .profiles
288 .get(&session.last_profile)
289 .with_context(|| format!("unknown profile {}", session.last_profile))?;
290 let locator = session.target.as_ref().context("session has no target")?;
291 let backend = backend_locator(locator, session, &self.config)?;
292 Ok(crate::controller::session_owns_profile_home(
293 &backend, session_id, profile,
294 ))
295 }
296
297 pub fn resource_probe(&self, session_id: &str) -> Result<targets::SessionResourceProbe> {
298 let session = self
299 .state
300 .sessions
301 .get(session_id)
302 .with_context(|| format!("unknown session {session_id}"))?;
303 let locator = session.target.as_ref().context("session has no target")?;
304 let backend = backend_locator(locator, session, &self.config)?;
305 targets::resource_probe(&backend, session_id)
306 }
307
308 pub fn deployment_capacity_targets(&self) -> Vec<targets::DeploymentCapacityTarget> {
309 use targets::{DeploymentCapacityKind, DeploymentCapacityTarget};
310
311 let mut local_ids = Vec::new();
312 let mut ssh_hosts: BTreeMap<String, (Vec<String>, Vec<CommandSpec>)> = BTreeMap::new();
313 let mut targets = Vec::new();
314 for (target_id, template) in &self.config.targets {
315 match template {
316 TargetTemplate::LocalBare
317 | TargetTemplate::LocalPodman { .. }
318 | TargetTemplate::LocalDocker { .. }
319 | TargetTemplate::AppleContainer { .. } => {
320 local_ids.push(target_id.clone());
321 }
322 TargetTemplate::SshBare { ssh, .. }
323 | TargetTemplate::SshPodman { ssh, .. }
324 | TargetTemplate::SshDocker { ssh, .. } => {
325 let entry = ssh_hosts.entry(ssh.host.clone()).or_default();
326 entry.0.push(target_id.clone());
327 let command = targets::ssh_host_capacity_command(&SshTarget::from(ssh));
328 if !entry.1.contains(&command) {
329 entry.1.push(command);
330 }
331 }
332 TargetTemplate::AwsEc2 { .. } => {
333 let mut probes = Vec::new();
334 let mut probe_error = None;
335 for session in self.state.sessions.values().filter(|session| {
336 session.target_template_id == *target_id
337 && session.state.is_active()
338 && session.target.is_some()
339 }) {
340 let result = backend_locator(
341 session.target.as_ref().expect("filtered target"),
342 session,
343 &self.config,
344 )
345 .and_then(|locator| {
346 targets::aws_allocated_capacity_command(&locator, &session.id)
347 });
348 match result {
349 Ok(command) => probes.push(command),
350 Err(error) => probe_error = Some(format!("{error:#}")),
351 }
352 }
353 targets.push(DeploymentCapacityTarget {
354 id: format!("aws:{target_id}"),
355 host: target_id.clone(),
356 target_ids: vec![target_id.clone()],
357 kind: DeploymentCapacityKind::AwsFleet,
358 local: false,
359 probes,
360 probe_error,
361 });
362 }
363 }
364 }
365 if !local_ids.is_empty() {
366 targets.push(DeploymentCapacityTarget {
367 id: "local".into(),
368 host: "local".into(),
369 target_ids: local_ids,
370 kind: DeploymentCapacityKind::Host,
371 local: true,
372 probes: Vec::new(),
373 probe_error: None,
374 });
375 }
376 targets.extend(ssh_hosts.into_iter().map(|(host, (target_ids, probes))| {
377 DeploymentCapacityTarget {
378 id: format!("ssh:{host}"),
379 host,
380 target_ids,
381 kind: DeploymentCapacityKind::Host,
382 local: false,
383 probes,
384 probe_error: None,
385 }
386 }));
387 targets.sort_by(|left, right| left.id.cmp(&right.id));
388 targets
389 }
390
391 pub fn test_target(&self, target_id: &str, executor: &impl CommandExecutor) -> Result<()> {
392 let template = self
393 .config
394 .targets
395 .get(target_id)
396 .with_context(|| format!("unknown target template {target_id:?}"))?;
397 preflight_target(template, executor)
398 }
399}
400
401pub(super) fn preflight_target(
402 template: &TargetTemplate,
403 executor: &impl CommandExecutor,
404) -> Result<()> {
405 match template {
406 TargetTemplate::LocalPodman { .. } => targets::verify_local_podman(executor)
407 .map(|_| ())
408 .map_err(|error| {
409 anyhow::anyhow!(
410 "local Podman is not ready. Fix the problem below, then Retry launch: {error:#}"
411 )
412 }),
413 TargetTemplate::LocalDocker { .. } => targets::verify_local_docker(executor)
414 .map(|_| ())
415 .map_err(|error| {
416 anyhow::anyhow!(
417 "local Docker is not ready. Start Docker or fix the problem below, then Retry launch: {error:#}"
418 )
419 }),
420 TargetTemplate::SshPodman { ssh, .. } => {
421 let ssh = SshTarget::from(ssh);
422 targets::verify_ssh_podman(&ssh, executor)
423 .map(|preflight| {
424 for warning in preflight.warnings {
425 executor.notify_notice(&warning.notice());
426 }
427 })
428 .map_err(|error| {
429 anyhow::anyhow!(
430 "remote Podman is not ready on {}. Fix the problem below, then Retry launch: {error:#}",
431 ssh.destination
432 )
433 })
434 }
435 TargetTemplate::SshDocker { ssh, .. } => {
436 let ssh = SshTarget::from(ssh);
437 targets::verify_ssh_docker(&ssh, executor)
438 .map(|_| ())
439 .map_err(|error| {
440 anyhow::anyhow!(
441 "remote Docker preflight failed for {}. Fix the problem below, then Retry launch: {error:#}",
442 ssh.destination
443 )
444 })
445 }
446 TargetTemplate::AppleContainer { .. } => {
447 let command = CommandSpec::new("container", ["system", "status"])
448 .purpose("preflight Apple container runtime")
449 .stage(ProvisionStage::Provisioning);
450 let output = executor.execute(&command).map_err(|error| {
451 anyhow::anyhow!(
452 "Apple container is not ready. Fix the problem below, then Retry launch: {error}"
453 )
454 })?;
455 if output.status != 0 {
456 bail!(
457 "Apple container is not ready. Start the runtime with `container system start`, then Retry launch: container system status exited {}: {}",
458 output.status,
459 [
460 String::from_utf8_lossy(&output.stdout).trim(),
461 String::from_utf8_lossy(&output.stderr).trim(),
462 ]
463 .into_iter()
464 .filter(|message| !message.is_empty())
465 .collect::<Vec<_>>()
466 .join("\n")
467 );
468 }
469 Ok(())
470 }
471 TargetTemplate::SshBare { ssh, .. } => {
472 let ssh = SshTarget::from(ssh);
473 let command = targets::ssh_connectivity_probe(&ssh);
474 let output = executor.execute(&command)?;
475 ensure!(
476 output.status == 0,
477 "SSH connectivity test failed for {} with status {}: {}",
478 ssh.destination,
479 output.status,
480 String::from_utf8_lossy(&output.stderr).trim()
481 );
482 Ok(())
483 }
484 TargetTemplate::AwsEc2 {
485 aws_profile,
486 region,
487 launch_template,
488 launch_template_version,
489 ..
490 } => {
491 let mut identity_args = vec!["sts".into(), "get-caller-identity".into()];
492 if let Some(profile) = aws_profile {
493 identity_args.extend(["--profile".into(), profile.clone()]);
494 }
495 let identity = CommandSpec::new("aws", identity_args)
496 .purpose("verify AWS credentials")
497 .stage(ProvisionStage::Provisioning);
498 let output = executor.execute(&identity)?;
499 ensure!(
500 output.status == 0,
501 "AWS credential test failed with status {}: {}",
502 output.status,
503 String::from_utf8_lossy(&output.stderr).trim()
504 );
505
506 let mut launch_args = vec![
507 "ec2".into(),
508 "describe-launch-template-versions".into(),
509 "--region".into(),
510 region.clone(),
511 "--launch-template-name".into(),
512 launch_template.clone(),
513 "--versions".into(),
514 launch_template_version
515 .clone()
516 .unwrap_or_else(|| "$Default".into()),
517 ];
518 if let Some(profile) = aws_profile {
519 launch_args.extend(["--profile".into(), profile.clone()]);
520 }
521 let launch = CommandSpec::new("aws", launch_args)
522 .purpose("verify AWS launch template")
523 .stage(ProvisionStage::Provisioning);
524 let output = executor.execute(&launch)?;
525 ensure!(
526 output.status == 0,
527 "AWS launch-template test failed with status {}: {}",
528 output.status,
529 String::from_utf8_lossy(&output.stderr).trim()
530 );
531 Ok(())
532 }
533 TargetTemplate::LocalBare => Ok(()),
534 }
535}
536
537pub(super) fn backend_bundle(
538 bundle: &ProjectBundle,
539 executor: &impl CommandExecutor,
540) -> Result<ProjectBundleSpec> {
541 let primary = bundle.primary().context("bundle primary is missing")?;
542 Ok(ProjectBundleSpec {
543 primary: primary.destination.to_string_lossy().into_owned(),
544 repositories: bundle
545 .repositories
546 .iter()
547 .map(|repository| {
548 let source = mj_core::remote_git::resolve_repository(repository, executor)
549 .with_context(|| format!("repository {:?}", repository.id))?;
550 Ok(RepositorySpec {
551 url: Some(source.fetch_url),
552 push_urls: source.push_urls,
553 destination: repository.destination.to_string_lossy().into_owned(),
554 git_ref: None,
555 reference: None,
556 })
557 })
558 .collect::<Result<Vec<_>>>()?,
559 })
560}
561
562#[derive(Debug, Clone, Copy, Default)]
566pub(super) struct ContainerOverrides<'a> {
567 pub cpus: Option<&'a str>,
568 pub memory: Option<&'a str>,
569}
570
571impl<'a> ContainerOverrides<'a> {
572 pub(super) fn for_session(session: &'a SessionRecord) -> Self {
573 Self {
574 cpus: session.container_cpus.as_deref(),
575 memory: session.container_memory.as_deref(),
576 }
577 }
578}
579
580pub(super) fn backend_target(
581 template: &TargetTemplate,
582 allocation: Option<&SessionResourceAllocation>,
583 overrides: ContainerOverrides<'_>,
584) -> Result<targets::TargetTemplate> {
585 Ok(match template {
586 TargetTemplate::LocalBare => targets::TargetTemplate::LocalBare,
587 TargetTemplate::LocalPodman { container } => {
588 let mut backend = backend_container(container, allocation, overrides);
589 backend.workspace_storage = (&container.workspace_storage).into();
590 targets::TargetTemplate::LocalPodman(backend)
591 }
592 TargetTemplate::LocalDocker { container } => targets::TargetTemplate::LocalDocker(
593 backend_container(container, allocation, overrides),
594 ),
595 TargetTemplate::AppleContainer { container } => targets::TargetTemplate::AppleContainer(
596 backend_container(container, allocation, overrides),
597 ),
598 TargetTemplate::AwsEc2 {
599 aws_profile,
600 region,
601 launch_template,
602 launch_template_version,
603 ssh_user,
604 identity_file,
605 ssh_args,
606 ..
607 } => targets::TargetTemplate::AwsEc2(AwsTemplate {
608 profile: aws_profile.clone().unwrap_or_else(|| "default".into()),
609 region: region.clone(),
610 launch_template: launch_template.clone(),
611 launch_template_version: launch_template_version.clone(),
612 instance_type: match allocation {
613 Some(SessionResourceAllocation::AwsEc2 { instance_type, .. }) => {
614 Some(instance_type.clone())
615 }
616 _ => None,
617 },
618 ssh: SshTarget {
620 destination: format!("{ssh_user}@pending.invalid"),
621 ssh_args: targets::ssh_args_with_identity(ssh_args, identity_file.as_deref()),
622 },
623 }),
624 TargetTemplate::SshBare {
625 ssh,
626 workspace_prefix,
627 ..
628 } => targets::TargetTemplate::SshBare {
629 ssh: SshTarget::from(ssh),
630 workspace_prefix: workspace_prefix.to_string_lossy().into_owned(),
631 },
632 TargetTemplate::SshPodman { ssh, container, .. } => {
633 let mut backend = backend_container(container, allocation, overrides);
634 backend.workspace_storage = (&container.workspace_storage).into();
635 targets::TargetTemplate::SshPodman {
636 ssh: SshTarget::from(ssh),
637 container: backend,
638 }
639 }
640 TargetTemplate::SshDocker { ssh, container, .. } => targets::TargetTemplate::SshDocker {
641 ssh: SshTarget::from(ssh),
642 container: backend_container(container, allocation, overrides),
643 },
644 })
645}
646
647pub fn image_refresh_plan(config: &Config) -> Vec<ImageRefresh> {
659 let mut plan: Vec<ImageRefresh> = Vec::new();
660 for target in config.targets.values() {
661 let Some((host, container)) = target.image_host() else {
662 continue;
663 };
664 let Some(refresh) = targets::image_refresh(
665 host,
666 &container.image,
667 container.platform.as_deref(),
668 container.pull_policy,
669 ) else {
670 continue;
671 };
672 if let Some(existing) = plan.iter_mut().find(|entry| {
675 entry.host == refresh.host
676 && entry.image == refresh.image
677 && entry.platform == refresh.platform
678 }) {
679 existing.when = existing.when.max(refresh.when);
680 continue;
681 }
682 plan.push(refresh);
683 }
684 plan
685}
686
687pub(crate) fn controller_github_token() -> Option<String> {
688 for name in ["GH_TOKEN", "GITHUB_TOKEN"] {
689 if let Ok(token) = std::env::var(name)
690 && let Some(token) = usable_github_token(&token)
691 {
692 return Some(token.to_owned());
693 }
694 }
695 let output = match Command::new("gh")
696 .args(["auth", "token", "--hostname", "github.com"])
697 .stdin(Stdio::null())
698 .stderr(Stdio::null())
699 .output()
700 {
701 Ok(output) => output,
702 Err(error) => {
703 tracing::debug!(%error, "could not query the GitHub CLI for a token");
704 return None;
705 }
706 };
707 if !output.status.success() {
708 tracing::debug!(status = ?output.status, "GitHub CLI did not return an authenticated token");
709 return None;
710 }
711 let token = match std::str::from_utf8(&output.stdout) {
712 Ok(token) => token,
713 Err(error) => {
714 tracing::debug!(%error, "GitHub CLI returned a non-UTF-8 token");
715 return None;
716 }
717 };
718 let Some(token) = usable_github_token(token) else {
719 tracing::debug!("GitHub CLI returned an empty or invalid token");
720 return None;
721 };
722 Some(token.to_owned())
723}
724
725fn usable_github_token(token: &str) -> Option<&str> {
726 let token = token.trim();
727 (!token.is_empty() && !token.chars().any(char::is_whitespace)).then_some(token)
728}
729
730pub(super) fn configure_github_token_environment(target: &mut targets::TargetTemplate) -> bool {
731 let container = match target {
732 targets::TargetTemplate::LocalPodman(container)
733 | targets::TargetTemplate::LocalDocker(container)
734 | targets::TargetTemplate::AppleContainer(container)
735 | targets::TargetTemplate::SshPodman { container, .. }
736 | targets::TargetTemplate::SshDocker { container, .. } => container,
737 targets::TargetTemplate::LocalBare
738 | targets::TargetTemplate::AwsEc2(_)
739 | targets::TargetTemplate::SshBare { .. } => return false,
740 };
741 container
742 .extra_run_args
743 .extend(["--env".to_owned(), "GH_TOKEN".to_owned()]);
744 true
745}
746
747pub(super) fn use_github_https_urls(bundle: &mut targets::ProjectBundleSpec) {
748 for repository in &mut bundle.repositories {
749 for source in repository
750 .url
751 .iter_mut()
752 .chain(repository.push_urls.iter_mut())
753 {
754 if let Some(github) = crate::setup::github_repository_from_origin(source) {
755 *source = format!(
756 "https://github.com/{}/{}.git",
757 github.owner, github.repository
758 );
759 }
760 }
761 }
762}
763
764fn backend_container(
765 container: &mj_core::config::ContainerTemplate,
766 allocation: Option<&SessionResourceAllocation>,
767 overrides: ContainerOverrides<'_>,
768) -> ContainerTemplate {
769 let mut extra_run_args = Vec::new();
770 if let Some(platform) = &container.platform {
771 extra_run_args.push(format!("--platform={platform}"));
772 }
773 let (cpus, memory) = match allocation {
774 Some(SessionResourceAllocation::Container { cpus, memory_bytes }) => {
775 (Some(cpus.to_string()), Some(memory_bytes.to_string()))
776 }
777 _ => (container.cpus.clone(), container.memory.clone()),
778 };
779 let cpus = overrides.cpus.map(str::to_owned).or(cpus);
781 let memory = overrides.memory.map(str::to_owned).or(memory);
782 if let Some(cpus) = cpus {
783 extra_run_args.push(format!("--cpus={cpus}"));
784 }
785 if let Some(memory) = memory {
786 extra_run_args.push(format!("--memory={memory}"));
787 }
788 for (key, value) in &container.environment {
789 extra_run_args.extend(["--env".to_string(), format!("{key}={value}")]);
790 }
791 ContainerTemplate {
792 image: container.image.clone(),
793 pull_policy: container.pull_policy,
794 extra_run_args,
795 workspace_storage: targets::PodmanWorkspaceStorage::ContainerLayer,
796 build_cache: container.build_cache.clone(),
797 }
798}
799
800pub(super) fn validate_resource_allocation(
801 template: &TargetTemplate,
802 allocation: Option<&SessionResourceAllocation>,
803) -> Result<()> {
804 if let Some(allocation) = allocation {
805 allocation.validate()?;
806 }
807 match (template, allocation) {
808 (_, None)
809 | (
810 TargetTemplate::LocalPodman { .. }
811 | TargetTemplate::LocalDocker { .. }
812 | TargetTemplate::AppleContainer { .. }
813 | TargetTemplate::SshPodman { .. }
814 | TargetTemplate::SshDocker { .. },
815 Some(SessionResourceAllocation::Container { .. }),
816 )
817 | (TargetTemplate::AwsEc2 { .. }, Some(SessionResourceAllocation::AwsEc2 { .. })) => Ok(()),
818 (TargetTemplate::LocalBare | TargetTemplate::SshBare { .. }, Some(_)) => {
819 bail!("bare targets have fixed host resources")
820 }
821 _ => bail!("resource allocation does not match the selected target kind"),
822 }
823}
824
825const AWS_SSH_READY_TIMEOUT: Duration = Duration::from_secs(300);
827
828const AWS_SSH_READY_RETRY_DELAY: Duration = Duration::from_secs(3);
829
830fn wait_for_ssh_ready(
835 executor: &impl CommandExecutor,
836 probe: &CommandSpec,
837 timeout: Duration,
838 mut now: impl FnMut() -> Instant,
839 mut sleep: impl FnMut(Duration),
840) -> Result<()> {
841 let started = now();
842 loop {
843 if executor.cancellation_requested() {
844 bail!("cancelled while waiting for SSH on the new instance");
845 }
846 let failure = match executor.execute(probe) {
847 Ok(output) if output.status == 0 => return Ok(()),
848 Ok(output) => String::from_utf8_lossy(&output.stderr).trim().to_string(),
849 Err(error) => error.to_string(),
850 };
851 if now().duration_since(started) >= timeout {
852 bail!(
853 "{} timed out after {}s: {}",
854 probe.purpose,
855 timeout.as_secs(),
856 if failure.is_empty() {
857 "the SSH probe reported no error output"
858 } else {
859 failure.as_str()
860 }
861 );
862 }
863 sleep(AWS_SSH_READY_RETRY_DELAY);
864 }
865}
866
867pub(super) fn locator_after_provision(
868 canonical: &TargetTemplate,
869 backend: &targets::TargetTemplate,
870 session_id: &str,
871 first_output: Option<&CommandOutput>,
872 executor: &(impl CommandExecutor + Sync),
873) -> Result<TargetLocator> {
874 let generated = targets::resource_name(session_id)?;
875 Ok(match canonical {
876 TargetTemplate::LocalBare => TargetLocator::LocalBare {
877 worker_root: data_dir().join("workers").join(session_id),
878 },
879 TargetTemplate::LocalPodman { .. } => {
880 let targets::TargetTemplate::LocalPodman(container) = backend else {
881 bail!("session locator/template mismatch")
882 };
883 TargetLocator::LocalPodman {
884 borrowed_from: None,
885 container_id: generated,
886 workspace_storage: PodmanWorkspaceLocator::from(targets::podman_workspace_locator(
887 container, session_id,
888 )?),
889 }
890 }
891 TargetTemplate::LocalDocker { .. } => TargetLocator::LocalDocker {
892 borrowed_from: None,
893 container_id: generated,
894 },
895 TargetTemplate::AppleContainer { .. } => TargetLocator::AppleContainer {
896 borrowed_from: None,
897 container_id: generated,
898 },
899 TargetTemplate::SshBare { ssh, .. } => TargetLocator::SshBare {
900 host: ssh.host.clone(),
901 workspace: PathBuf::from(targets::workspace_for(backend, session_id)?),
902 worker_id: None,
903 },
904 TargetTemplate::SshPodman { ssh, .. } => {
905 let targets::TargetTemplate::SshPodman { container, .. } = backend else {
906 bail!("session locator/template mismatch")
907 };
908 TargetLocator::SshPodman {
909 borrowed_from: None,
910 host: ssh.host.clone(),
911 container_id: generated,
912 workspace_storage: PodmanWorkspaceLocator::from(targets::podman_workspace_locator(
913 container, session_id,
914 )?),
915 }
916 }
917 TargetTemplate::SshDocker { ssh, .. } => TargetLocator::SshDocker {
918 borrowed_from: None,
919 host: ssh.host.clone(),
920 container_id: generated,
921 },
922 TargetTemplate::AwsEc2 {
923 aws_profile,
924 region,
925 ssh_user,
926 address_source,
927 identity_file,
928 ssh_args,
929 ..
930 } => {
931 let output = first_output.context("AWS launch produced no output")?;
932 let json: serde_json::Value = serde_json::from_slice(&output.stdout)
933 .context("parse aws ec2 run-instances response")?;
934 let instance_id = json
935 .pointer("/Instances/0/InstanceId")
936 .and_then(serde_json::Value::as_str)
937 .context("AWS response omitted instance ID")?
938 .to_string();
939 let profile = aws_profile.clone().unwrap_or_else(|| "default".into());
940 execute_checked(
941 executor,
942 CommandSpec::new(
943 "aws",
944 [
945 "--profile".into(),
946 profile.clone(),
947 "--region".into(),
948 region.clone(),
949 "ec2".into(),
950 "wait".into(),
951 "instance-running".into(),
952 "--instance-ids".into(),
953 instance_id.clone(),
954 ],
955 )
956 .purpose("wait for EC2 session instance to run")
957 .stage(ProvisionStage::Booting),
958 )?;
959 let field = match address_source {
960 AwsAddressSource::PublicDns => "PublicDnsName",
961 AwsAddressSource::PublicIp => "PublicIpAddress",
962 AwsAddressSource::PrivateDns => "PrivateDnsName",
963 AwsAddressSource::PrivateIp => "PrivateIpAddress",
964 };
965 let address = execute_checked(
966 executor,
967 CommandSpec::new(
968 "aws",
969 [
970 "--profile".into(),
971 profile.clone(),
972 "--region".into(),
973 region.clone(),
974 "ec2".into(),
975 "describe-instances".into(),
976 "--instance-ids".into(),
977 instance_id.clone(),
978 "--query".into(),
979 format!("Reservations[0].Instances[0].{field}"),
980 "--output".into(),
981 "text".into(),
982 ],
983 )
984 .purpose("resolve EC2 session address")
985 .stage(ProvisionStage::Booting),
986 )?;
987 let address = String::from_utf8(address.stdout)
988 .context("AWS address was not UTF-8")?
989 .trim()
990 .to_string();
991 if address.is_empty() || address == "None" {
992 bail!("AWS instance {instance_id} has no configured address");
993 }
994 let ssh = SshTarget {
995 destination: format!("{ssh_user}@{address}"),
996 ssh_args: targets::ssh_args_with_identity(ssh_args, identity_file.as_deref()),
997 };
998 wait_for_ssh_ready(
999 executor,
1000 &crate::targets::ssh_command(&ssh, ["true"])
1001 .purpose("wait for EC2 SSH availability")
1002 .stage(ProvisionStage::Booting),
1003 AWS_SSH_READY_TIMEOUT,
1004 Instant::now,
1005 std::thread::sleep,
1006 )?;
1007 TargetLocator::AwsEc2 {
1008 instance_id,
1009 address: Some(address),
1010 }
1011 }
1012 })
1013}
1014
1015pub(super) fn backend_locator(
1020 locator: &TargetLocator,
1021 session: &SessionRecord,
1022 config: &Config,
1023) -> Result<targets::TargetLocator> {
1024 let template = config
1025 .targets
1026 .get(&session.target_template_id)
1027 .context("session target template is missing")?;
1028 Ok(targets::TargetLocator::try_from(targets::StoredTarget {
1029 locator,
1030 template,
1031 session_id: &session.id,
1032 })?)
1033}
1034
1035#[cfg(test)]
1036mod tests;