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, ImageHost,
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 resolve_aws_resource_options(
79 &self,
80 target_id: &str,
81 executor: &impl CommandExecutor,
82 ) -> Result<Vec<SessionResourceAllocation>> {
83 let TargetTemplate::AwsEc2 {
84 aws_profile,
85 region,
86 launch_template,
87 launch_template_version,
88 ..
89 } = self
90 .config
91 .targets
92 .get(target_id)
93 .with_context(|| format!("unknown target template {target_id:?}"))?
94 else {
95 bail!("target {target_id:?} is not an AWS EC2 target");
96 };
97 let profile = aws_profile.as_deref().unwrap_or("default");
98 let launch_key = if launch_template.starts_with("lt-") {
99 "--launch-template-id"
100 } else {
101 "--launch-template-name"
102 };
103 let version = launch_template_version.as_deref().unwrap_or("$Default");
104 let describe_template = CommandSpec::new(
105 "aws",
106 [
107 "--profile",
108 profile,
109 "--region",
110 region,
111 "ec2",
112 "describe-launch-template-versions",
113 launch_key,
114 launch_template,
115 "--versions",
116 version,
117 "--output",
118 "json",
119 ],
120 )
121 .purpose("resolve EC2 launch template instance family");
122 let output = executor.execute(&describe_template)?;
123 if output.status != 0 {
124 bail!(
125 "{} failed with status {}: {}",
126 describe_template.purpose,
127 output.status,
128 String::from_utf8_lossy(&output.stderr).trim()
129 );
130 }
131 let response: serde_json::Value =
132 serde_json::from_slice(&output.stdout).context("parse EC2 launch template response")?;
133 let instance_type = response
134 .pointer("/LaunchTemplateVersions/0/LaunchTemplateData/InstanceType")
135 .and_then(serde_json::Value::as_str)
136 .context("launch template does not specify a concrete instance type")?;
137 let family = instance_type
138 .rsplit_once('.')
139 .map(|(family, _)| family)
140 .context("launch template instance type has no size suffix")?;
141 let filter = format!("Name=instance-type,Values={family}.*");
142 let describe_types = CommandSpec::new(
143 "aws",
144 [
145 "--profile",
146 profile,
147 "--region",
148 region,
149 "ec2",
150 "describe-instance-types",
151 "--filters",
152 &filter,
153 "--output",
154 "json",
155 ],
156 )
157 .purpose("discover EC2 instance sizes");
158 let output = executor.execute(&describe_types)?;
159 if output.status != 0 {
160 bail!(
161 "{} failed with status {}: {}",
162 describe_types.purpose,
163 output.status,
164 String::from_utf8_lossy(&output.stderr).trim()
165 );
166 }
167 let response: serde_json::Value =
168 serde_json::from_slice(&output.stdout).context("parse EC2 instance type response")?;
169 let mut options = response
170 .get("InstanceTypes")
171 .and_then(serde_json::Value::as_array)
172 .context("EC2 instance type response omitted InstanceTypes")?
173 .iter()
174 .filter_map(|entry| {
175 Some(SessionResourceAllocation::AwsEc2 {
176 instance_type: entry.get("InstanceType")?.as_str()?.to_owned(),
177 vcpus: entry.pointer("/VCpuInfo/DefaultVCpus")?.as_u64()?,
178 memory_bytes: entry
179 .pointer("/MemoryInfo/SizeInMiB")?
180 .as_u64()?
181 .checked_mul(1024 * 1024)?,
182 })
183 })
184 .collect::<Vec<_>>();
185 options.sort_by_key(allocation_cpus);
186 if !options.iter().any(|option| allocation_cpus(option) == 8) {
187 bail!("EC2 family {family:?} has no exact 8-vCPU baseline size");
188 }
189 Ok(options)
190 }
191
192 pub fn reconnect_command(&self, session_id: &str) -> Result<CommandSpec> {
193 let session = self
194 .state
195 .sessions
196 .get(session_id)
197 .with_context(|| format!("unknown session {session_id}"))?;
198 session.validate_configuration(&self.config)?;
199 let locator = session.target.as_ref().context("session has no target")?;
200 let backend = backend_locator(locator, session, &self.config)?;
201 targets::reconnect_plan(&backend, session_id)?
202 .commands
203 .into_iter()
204 .next()
205 .context("reconnect plan is empty")
206 }
207
208 pub fn resource_probe(&self, session_id: &str) -> Result<targets::SessionResourceProbe> {
209 let session = self
210 .state
211 .sessions
212 .get(session_id)
213 .with_context(|| format!("unknown session {session_id}"))?;
214 let locator = session.target.as_ref().context("session has no target")?;
215 let backend = backend_locator(locator, session, &self.config)?;
216 targets::resource_probe(&backend, session_id)
217 }
218
219 pub fn deployment_capacity_targets(&self) -> Vec<targets::DeploymentCapacityTarget> {
220 use targets::{DeploymentCapacityKind, DeploymentCapacityTarget};
221
222 let mut local_ids = Vec::new();
223 let mut ssh_hosts: BTreeMap<String, (Vec<String>, Vec<CommandSpec>)> = BTreeMap::new();
224 let mut targets = Vec::new();
225 for (target_id, template) in &self.config.targets {
226 match template {
227 TargetTemplate::LocalBare
228 | TargetTemplate::LocalPodman { .. }
229 | TargetTemplate::LocalDocker { .. }
230 | TargetTemplate::AppleContainer { .. } => {
231 local_ids.push(target_id.clone());
232 }
233 TargetTemplate::SshBare { ssh, .. }
234 | TargetTemplate::SshPodman { ssh, .. }
235 | TargetTemplate::SshDocker { ssh, .. } => {
236 let entry = ssh_hosts.entry(ssh.host.clone()).or_default();
237 entry.0.push(target_id.clone());
238 let command = targets::ssh_host_capacity_command(&SshTarget::from(ssh));
239 if !entry.1.contains(&command) {
240 entry.1.push(command);
241 }
242 }
243 TargetTemplate::AwsEc2 { .. } => {
244 let mut probes = Vec::new();
245 let mut probe_error = None;
246 for session in self.state.sessions.values().filter(|session| {
247 session.target_template_id == *target_id
248 && session.state.is_active()
249 && session.target.is_some()
250 }) {
251 let result = backend_locator(
252 session.target.as_ref().expect("filtered target"),
253 session,
254 &self.config,
255 )
256 .and_then(|locator| {
257 targets::aws_allocated_capacity_command(&locator, &session.id)
258 });
259 match result {
260 Ok(command) => probes.push(command),
261 Err(error) => probe_error = Some(format!("{error:#}")),
262 }
263 }
264 targets.push(DeploymentCapacityTarget {
265 id: format!("aws:{target_id}"),
266 host: target_id.clone(),
267 target_ids: vec![target_id.clone()],
268 kind: DeploymentCapacityKind::AwsFleet,
269 local: false,
270 probes,
271 probe_error,
272 });
273 }
274 }
275 }
276 if !local_ids.is_empty() {
277 targets.push(DeploymentCapacityTarget {
278 id: "local".into(),
279 host: "local".into(),
280 target_ids: local_ids,
281 kind: DeploymentCapacityKind::Host,
282 local: true,
283 probes: Vec::new(),
284 probe_error: None,
285 });
286 }
287 targets.extend(ssh_hosts.into_iter().map(|(host, (target_ids, probes))| {
288 DeploymentCapacityTarget {
289 id: format!("ssh:{host}"),
290 host,
291 target_ids,
292 kind: DeploymentCapacityKind::Host,
293 local: false,
294 probes,
295 probe_error: None,
296 }
297 }));
298 targets.sort_by(|left, right| left.id.cmp(&right.id));
299 targets
300 }
301
302 pub fn test_target(&self, target_id: &str, executor: &impl CommandExecutor) -> Result<()> {
303 let template = self
304 .config
305 .targets
306 .get(target_id)
307 .with_context(|| format!("unknown target template {target_id:?}"))?;
308 preflight_target(template, executor)
309 }
310}
311
312pub(super) fn preflight_target(
313 template: &TargetTemplate,
314 executor: &impl CommandExecutor,
315) -> Result<()> {
316 match template {
317 TargetTemplate::LocalPodman { .. } => targets::verify_local_podman(executor)
318 .map(|_| ())
319 .map_err(|error| {
320 anyhow::anyhow!(
321 "local Podman is not ready. Fix the problem below, then Retry launch: {error:#}"
322 )
323 }),
324 TargetTemplate::LocalDocker { .. } => targets::verify_local_docker(executor)
325 .map(|_| ())
326 .map_err(|error| {
327 anyhow::anyhow!(
328 "local Docker is not ready. Start Docker or fix the problem below, then Retry launch: {error:#}"
329 )
330 }),
331 TargetTemplate::SshPodman { ssh, .. } => {
332 let ssh = SshTarget::from(ssh);
333 targets::verify_ssh_podman(&ssh, executor)
334 .map(|preflight| {
335 for warning in preflight.warnings {
336 executor.notify_notice(&warning.notice());
337 }
338 })
339 .map_err(|error| {
340 anyhow::anyhow!(
341 "remote Podman is not ready on {}. Fix the problem below, then Retry launch: {error:#}",
342 ssh.destination
343 )
344 })
345 }
346 TargetTemplate::SshDocker { ssh, .. } => {
347 let ssh = SshTarget::from(ssh);
348 targets::verify_ssh_docker(&ssh, executor)
349 .map(|_| ())
350 .map_err(|error| {
351 anyhow::anyhow!(
352 "remote Docker preflight failed for {}. Fix the problem below, then Retry launch: {error:#}",
353 ssh.destination
354 )
355 })
356 }
357 TargetTemplate::AppleContainer { .. } => {
358 let command = CommandSpec::new("container", ["system", "status"])
359 .purpose("preflight Apple container runtime")
360 .stage(ProvisionStage::Provisioning);
361 let output = executor.execute(&command).map_err(|error| {
362 anyhow::anyhow!(
363 "Apple container is not ready. Fix the problem below, then Retry launch: {error}"
364 )
365 })?;
366 if output.status != 0 {
367 bail!(
368 "Apple container is not ready. Start the runtime with `container system start`, then Retry launch: container system status exited {}: {}",
369 output.status,
370 [
371 String::from_utf8_lossy(&output.stdout).trim(),
372 String::from_utf8_lossy(&output.stderr).trim(),
373 ]
374 .into_iter()
375 .filter(|message| !message.is_empty())
376 .collect::<Vec<_>>()
377 .join("\n")
378 );
379 }
380 Ok(())
381 }
382 TargetTemplate::SshBare { ssh, .. } => {
383 let ssh = SshTarget::from(ssh);
384 let command = targets::ssh_connectivity_probe(&ssh);
385 let output = executor.execute(&command)?;
386 ensure!(
387 output.status == 0,
388 "SSH connectivity test failed for {} with status {}: {}",
389 ssh.destination,
390 output.status,
391 String::from_utf8_lossy(&output.stderr).trim()
392 );
393 Ok(())
394 }
395 TargetTemplate::AwsEc2 {
396 aws_profile,
397 region,
398 launch_template,
399 launch_template_version,
400 ..
401 } => {
402 let mut identity_args = vec!["sts".into(), "get-caller-identity".into()];
403 if let Some(profile) = aws_profile {
404 identity_args.extend(["--profile".into(), profile.clone()]);
405 }
406 let identity = CommandSpec::new("aws", identity_args)
407 .purpose("verify AWS credentials")
408 .stage(ProvisionStage::Provisioning);
409 let output = executor.execute(&identity)?;
410 ensure!(
411 output.status == 0,
412 "AWS credential test failed with status {}: {}",
413 output.status,
414 String::from_utf8_lossy(&output.stderr).trim()
415 );
416
417 let mut launch_args = vec![
418 "ec2".into(),
419 "describe-launch-template-versions".into(),
420 "--region".into(),
421 region.clone(),
422 "--launch-template-name".into(),
423 launch_template.clone(),
424 "--versions".into(),
425 launch_template_version
426 .clone()
427 .unwrap_or_else(|| "$Default".into()),
428 ];
429 if let Some(profile) = aws_profile {
430 launch_args.extend(["--profile".into(), profile.clone()]);
431 }
432 let launch = CommandSpec::new("aws", launch_args)
433 .purpose("verify AWS launch template")
434 .stage(ProvisionStage::Provisioning);
435 let output = executor.execute(&launch)?;
436 ensure!(
437 output.status == 0,
438 "AWS launch-template test failed with status {}: {}",
439 output.status,
440 String::from_utf8_lossy(&output.stderr).trim()
441 );
442 Ok(())
443 }
444 TargetTemplate::LocalBare => Ok(()),
445 }
446}
447
448pub(super) fn backend_bundle(
449 bundle: &ProjectBundle,
450 executor: &impl CommandExecutor,
451) -> Result<ProjectBundleSpec> {
452 let primary = bundle.primary().context("bundle primary is missing")?;
453 Ok(ProjectBundleSpec {
454 primary: primary.destination.to_string_lossy().into_owned(),
455 repositories: bundle
456 .repositories
457 .iter()
458 .map(|repository| {
459 let source = mj_core::remote_git::resolve_repository(repository, executor)
460 .with_context(|| format!("repository {:?}", repository.id))?;
461 Ok(RepositorySpec {
462 url: Some(source.fetch_url),
463 push_urls: source.push_urls,
464 destination: repository.destination.to_string_lossy().into_owned(),
465 git_ref: None,
466 reference: None,
467 })
468 })
469 .collect::<Result<Vec<_>>>()?,
470 })
471}
472
473#[derive(Debug, Clone, Copy, Default)]
477pub(super) struct ContainerOverrides<'a> {
478 pub cpus: Option<&'a str>,
479 pub memory: Option<&'a str>,
480}
481
482impl<'a> ContainerOverrides<'a> {
483 pub(super) fn for_session(session: &'a SessionRecord) -> Self {
484 Self {
485 cpus: session.container_cpus.as_deref(),
486 memory: session.container_memory.as_deref(),
487 }
488 }
489}
490
491pub(super) fn backend_target(
492 template: &TargetTemplate,
493 allocation: Option<&SessionResourceAllocation>,
494 overrides: ContainerOverrides<'_>,
495) -> Result<targets::TargetTemplate> {
496 Ok(match template {
497 TargetTemplate::LocalBare => targets::TargetTemplate::LocalBare,
498 TargetTemplate::LocalPodman { container } => {
499 let mut backend = backend_container(container, allocation, overrides);
500 backend.workspace_storage = (&container.workspace_storage).into();
501 targets::TargetTemplate::LocalPodman(backend)
502 }
503 TargetTemplate::LocalDocker { container } => targets::TargetTemplate::LocalDocker(
504 backend_container(container, allocation, overrides),
505 ),
506 TargetTemplate::AppleContainer { container } => targets::TargetTemplate::AppleContainer(
507 backend_container(container, allocation, overrides),
508 ),
509 TargetTemplate::AwsEc2 {
510 aws_profile,
511 region,
512 launch_template,
513 launch_template_version,
514 ssh_user,
515 identity_file,
516 ssh_args,
517 ..
518 } => targets::TargetTemplate::AwsEc2(AwsTemplate {
519 profile: aws_profile.clone().unwrap_or_else(|| "default".into()),
520 region: region.clone(),
521 launch_template: launch_template.clone(),
522 launch_template_version: launch_template_version.clone(),
523 instance_type: match allocation {
524 Some(SessionResourceAllocation::AwsEc2 { instance_type, .. }) => {
525 Some(instance_type.clone())
526 }
527 _ => None,
528 },
529 ssh: SshTarget {
531 destination: format!("{ssh_user}@pending.invalid"),
532 ssh_args: targets::ssh_args_with_identity(ssh_args, identity_file.as_deref()),
533 },
534 }),
535 TargetTemplate::SshBare {
536 ssh,
537 workspace_prefix,
538 ..
539 } => targets::TargetTemplate::SshBare {
540 ssh: SshTarget::from(ssh),
541 workspace_prefix: workspace_prefix.to_string_lossy().into_owned(),
542 },
543 TargetTemplate::SshPodman { ssh, container, .. } => {
544 let mut backend = backend_container(container, allocation, overrides);
545 backend.workspace_storage = (&container.workspace_storage).into();
546 targets::TargetTemplate::SshPodman {
547 ssh: SshTarget::from(ssh),
548 container: backend,
549 }
550 }
551 TargetTemplate::SshDocker { ssh, container, .. } => targets::TargetTemplate::SshDocker {
552 ssh: SshTarget::from(ssh),
553 container: backend_container(container, allocation, overrides),
554 },
555 })
556}
557
558pub fn image_refresh_plan(config: &Config) -> Vec<ImageRefresh> {
566 let mut plan: Vec<ImageRefresh> = Vec::new();
567 for target in config.targets.values() {
568 let (host, container) = match target {
569 TargetTemplate::LocalPodman { container } => (ImageHost::LocalPodman, container),
570 TargetTemplate::LocalDocker { container } => (ImageHost::LocalDocker, container),
571 TargetTemplate::SshPodman { ssh, container } => {
572 (ImageHost::SshPodman(SshTarget::from(ssh)), container)
573 }
574 TargetTemplate::SshDocker { ssh, container } => {
575 (ImageHost::SshDocker(SshTarget::from(ssh)), container)
576 }
577 TargetTemplate::LocalBare
578 | TargetTemplate::AppleContainer { .. }
579 | TargetTemplate::AwsEc2 { .. }
580 | TargetTemplate::SshBare { .. } => continue,
581 };
582 let Some(refresh) = targets::image_refresh(
585 host,
586 &container.image,
587 container.platform.as_deref(),
588 container.pull_policy,
589 ) else {
590 continue;
591 };
592 if !plan.contains(&refresh) {
593 plan.push(refresh);
594 }
595 }
596 plan
597}
598
599pub(crate) fn controller_github_token() -> Option<String> {
600 for name in ["GH_TOKEN", "GITHUB_TOKEN"] {
601 if let Ok(token) = std::env::var(name)
602 && let Some(token) = usable_github_token(&token)
603 {
604 return Some(token.to_owned());
605 }
606 }
607 let output = match Command::new("gh")
608 .args(["auth", "token", "--hostname", "github.com"])
609 .stdin(Stdio::null())
610 .stderr(Stdio::null())
611 .output()
612 {
613 Ok(output) => output,
614 Err(error) => {
615 tracing::debug!(%error, "could not query the GitHub CLI for a token");
616 return None;
617 }
618 };
619 if !output.status.success() {
620 tracing::debug!(status = ?output.status, "GitHub CLI did not return an authenticated token");
621 return None;
622 }
623 let token = match std::str::from_utf8(&output.stdout) {
624 Ok(token) => token,
625 Err(error) => {
626 tracing::debug!(%error, "GitHub CLI returned a non-UTF-8 token");
627 return None;
628 }
629 };
630 let Some(token) = usable_github_token(token) else {
631 tracing::debug!("GitHub CLI returned an empty or invalid token");
632 return None;
633 };
634 Some(token.to_owned())
635}
636
637fn usable_github_token(token: &str) -> Option<&str> {
638 let token = token.trim();
639 (!token.is_empty() && !token.chars().any(char::is_whitespace)).then_some(token)
640}
641
642pub(super) fn configure_github_token_environment(target: &mut targets::TargetTemplate) -> bool {
643 let container = match target {
644 targets::TargetTemplate::LocalPodman(container)
645 | targets::TargetTemplate::LocalDocker(container)
646 | targets::TargetTemplate::AppleContainer(container)
647 | targets::TargetTemplate::SshPodman { container, .. }
648 | targets::TargetTemplate::SshDocker { container, .. } => container,
649 targets::TargetTemplate::LocalBare
650 | targets::TargetTemplate::AwsEc2(_)
651 | targets::TargetTemplate::SshBare { .. } => return false,
652 };
653 container
654 .extra_run_args
655 .extend(["--env".to_owned(), "GH_TOKEN".to_owned()]);
656 true
657}
658
659pub(super) fn use_github_https_urls(bundle: &mut targets::ProjectBundleSpec) {
660 for repository in &mut bundle.repositories {
661 for source in repository
662 .url
663 .iter_mut()
664 .chain(repository.push_urls.iter_mut())
665 {
666 if let Some(github) = crate::setup::github_repository_from_origin(source) {
667 *source = format!(
668 "https://github.com/{}/{}.git",
669 github.owner, github.repository
670 );
671 }
672 }
673 }
674}
675
676fn backend_container(
677 container: &mj_core::config::ContainerTemplate,
678 allocation: Option<&SessionResourceAllocation>,
679 overrides: ContainerOverrides<'_>,
680) -> ContainerTemplate {
681 let mut extra_run_args = Vec::new();
682 if let Some(platform) = &container.platform {
683 extra_run_args.push(format!("--platform={platform}"));
684 }
685 let (cpus, memory) = match allocation {
686 Some(SessionResourceAllocation::Container { cpus, memory_bytes }) => {
687 (Some(cpus.to_string()), Some(memory_bytes.to_string()))
688 }
689 _ => (container.cpus.clone(), container.memory.clone()),
690 };
691 let cpus = overrides.cpus.map(str::to_owned).or(cpus);
693 let memory = overrides.memory.map(str::to_owned).or(memory);
694 if let Some(cpus) = cpus {
695 extra_run_args.push(format!("--cpus={cpus}"));
696 }
697 if let Some(memory) = memory {
698 extra_run_args.push(format!("--memory={memory}"));
699 }
700 for (key, value) in &container.environment {
701 extra_run_args.extend(["--env".to_string(), format!("{key}={value}")]);
702 }
703 ContainerTemplate {
704 image: container.image.clone(),
705 pull_policy: container.pull_policy,
706 extra_run_args,
707 workspace_storage: targets::PodmanWorkspaceStorage::ContainerLayer,
708 build_cache: container.build_cache.clone(),
709 }
710}
711
712pub(super) fn validate_resource_allocation(
713 template: &TargetTemplate,
714 allocation: Option<&SessionResourceAllocation>,
715) -> Result<()> {
716 if let Some(allocation) = allocation {
717 allocation.validate()?;
718 }
719 match (template, allocation) {
720 (_, None)
721 | (
722 TargetTemplate::LocalPodman { .. }
723 | TargetTemplate::LocalDocker { .. }
724 | TargetTemplate::AppleContainer { .. }
725 | TargetTemplate::SshPodman { .. }
726 | TargetTemplate::SshDocker { .. },
727 Some(SessionResourceAllocation::Container { .. }),
728 )
729 | (TargetTemplate::AwsEc2 { .. }, Some(SessionResourceAllocation::AwsEc2 { .. })) => Ok(()),
730 (TargetTemplate::LocalBare | TargetTemplate::SshBare { .. }, Some(_)) => {
731 bail!("bare targets have fixed host resources")
732 }
733 _ => bail!("resource allocation does not match the selected target kind"),
734 }
735}
736
737const AWS_SSH_READY_TIMEOUT: Duration = Duration::from_secs(300);
739
740const AWS_SSH_READY_RETRY_DELAY: Duration = Duration::from_secs(3);
741
742fn wait_for_ssh_ready(
747 executor: &impl CommandExecutor,
748 probe: &CommandSpec,
749 timeout: Duration,
750 mut now: impl FnMut() -> Instant,
751 mut sleep: impl FnMut(Duration),
752) -> Result<()> {
753 let started = now();
754 loop {
755 if executor.cancellation_requested() {
756 bail!("cancelled while waiting for SSH on the new instance");
757 }
758 let failure = match executor.execute(probe) {
759 Ok(output) if output.status == 0 => return Ok(()),
760 Ok(output) => String::from_utf8_lossy(&output.stderr).trim().to_string(),
761 Err(error) => error.to_string(),
762 };
763 if now().duration_since(started) >= timeout {
764 bail!(
765 "{} timed out after {}s: {}",
766 probe.purpose,
767 timeout.as_secs(),
768 if failure.is_empty() {
769 "the SSH probe reported no error output"
770 } else {
771 failure.as_str()
772 }
773 );
774 }
775 sleep(AWS_SSH_READY_RETRY_DELAY);
776 }
777}
778
779pub(super) fn locator_after_provision(
780 canonical: &TargetTemplate,
781 backend: &targets::TargetTemplate,
782 session_id: &str,
783 first_output: Option<&CommandOutput>,
784 executor: &(impl CommandExecutor + Sync),
785) -> Result<TargetLocator> {
786 let generated = targets::resource_name(session_id)?;
787 Ok(match canonical {
788 TargetTemplate::LocalBare => TargetLocator::LocalBare {
789 worker_root: data_dir().join("workers").join(session_id),
790 },
791 TargetTemplate::LocalPodman { .. } => {
792 let targets::TargetTemplate::LocalPodman(container) = backend else {
793 bail!("session locator/template mismatch")
794 };
795 TargetLocator::LocalPodman {
796 borrowed_from: None,
797 container_id: generated,
798 workspace_storage: PodmanWorkspaceLocator::from(targets::podman_workspace_locator(
799 container, session_id,
800 )?),
801 }
802 }
803 TargetTemplate::LocalDocker { .. } => TargetLocator::LocalDocker {
804 borrowed_from: None,
805 container_id: generated,
806 },
807 TargetTemplate::AppleContainer { .. } => TargetLocator::AppleContainer {
808 borrowed_from: None,
809 container_id: generated,
810 },
811 TargetTemplate::SshBare { ssh, .. } => TargetLocator::SshBare {
812 host: ssh.host.clone(),
813 workspace: PathBuf::from(targets::workspace_for(backend, session_id)?),
814 worker_id: None,
815 },
816 TargetTemplate::SshPodman { ssh, .. } => {
817 let targets::TargetTemplate::SshPodman { container, .. } = backend else {
818 bail!("session locator/template mismatch")
819 };
820 TargetLocator::SshPodman {
821 borrowed_from: None,
822 host: ssh.host.clone(),
823 container_id: generated,
824 workspace_storage: PodmanWorkspaceLocator::from(targets::podman_workspace_locator(
825 container, session_id,
826 )?),
827 }
828 }
829 TargetTemplate::SshDocker { ssh, .. } => TargetLocator::SshDocker {
830 borrowed_from: None,
831 host: ssh.host.clone(),
832 container_id: generated,
833 },
834 TargetTemplate::AwsEc2 {
835 aws_profile,
836 region,
837 ssh_user,
838 address_source,
839 identity_file,
840 ssh_args,
841 ..
842 } => {
843 let output = first_output.context("AWS launch produced no output")?;
844 let json: serde_json::Value = serde_json::from_slice(&output.stdout)
845 .context("parse aws ec2 run-instances response")?;
846 let instance_id = json
847 .pointer("/Instances/0/InstanceId")
848 .and_then(serde_json::Value::as_str)
849 .context("AWS response omitted instance ID")?
850 .to_string();
851 let profile = aws_profile.clone().unwrap_or_else(|| "default".into());
852 execute_checked(
853 executor,
854 CommandSpec::new(
855 "aws",
856 [
857 "--profile".into(),
858 profile.clone(),
859 "--region".into(),
860 region.clone(),
861 "ec2".into(),
862 "wait".into(),
863 "instance-running".into(),
864 "--instance-ids".into(),
865 instance_id.clone(),
866 ],
867 )
868 .purpose("wait for EC2 session instance to run")
869 .stage(ProvisionStage::Booting),
870 )?;
871 let field = match address_source {
872 AwsAddressSource::PublicDns => "PublicDnsName",
873 AwsAddressSource::PublicIp => "PublicIpAddress",
874 AwsAddressSource::PrivateDns => "PrivateDnsName",
875 AwsAddressSource::PrivateIp => "PrivateIpAddress",
876 };
877 let address = execute_checked(
878 executor,
879 CommandSpec::new(
880 "aws",
881 [
882 "--profile".into(),
883 profile.clone(),
884 "--region".into(),
885 region.clone(),
886 "ec2".into(),
887 "describe-instances".into(),
888 "--instance-ids".into(),
889 instance_id.clone(),
890 "--query".into(),
891 format!("Reservations[0].Instances[0].{field}"),
892 "--output".into(),
893 "text".into(),
894 ],
895 )
896 .purpose("resolve EC2 session address")
897 .stage(ProvisionStage::Booting),
898 )?;
899 let address = String::from_utf8(address.stdout)
900 .context("AWS address was not UTF-8")?
901 .trim()
902 .to_string();
903 if address.is_empty() || address == "None" {
904 bail!("AWS instance {instance_id} has no configured address");
905 }
906 let ssh = SshTarget {
907 destination: format!("{ssh_user}@{address}"),
908 ssh_args: targets::ssh_args_with_identity(ssh_args, identity_file.as_deref()),
909 };
910 wait_for_ssh_ready(
911 executor,
912 &crate::targets::ssh_command(&ssh, ["true"])
913 .purpose("wait for EC2 SSH availability")
914 .stage(ProvisionStage::Booting),
915 AWS_SSH_READY_TIMEOUT,
916 Instant::now,
917 std::thread::sleep,
918 )?;
919 TargetLocator::AwsEc2 {
920 instance_id,
921 address: Some(address),
922 }
923 }
924 })
925}
926
927pub(super) fn backend_locator(
932 locator: &TargetLocator,
933 session: &SessionRecord,
934 config: &Config,
935) -> Result<targets::TargetLocator> {
936 let template = config
937 .targets
938 .get(&session.target_template_id)
939 .context("session target template is missing")?;
940 Ok(targets::TargetLocator::try_from(targets::StoredTarget {
941 locator,
942 template,
943 session_id: &session.id,
944 })?)
945}
946
947#[cfg(test)]
948mod tests;