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 hel::hel_config::{
11 AwsAddressSource, HelConfig, PodmanWorkspaceStorage, ProjectBundle, TargetTemplate, data_dir,
12};
13use hel::hel_state::{
14 PodmanWorkspaceLocator, SessionRecord, SessionResourceAllocation, TargetLocator,
15 allocation_cpus,
16};
17use hel::hel_targets::{
18 self, AwsTemplate, CommandExecutor, CommandOutput, CommandSpec, ContainerTemplate, ImageHost,
19 ImageRefresh, ProjectBundleSpec, ProvisionStage, RepositorySpec, SshTarget,
20};
21
22use super::{Controller, backend_ssh, execute_checked, ssh_args_with_identity, ssh_command_spec};
23
24impl Controller {
25 pub fn resolve_aws_resource_options(
26 &self,
27 target_id: &str,
28 executor: &impl CommandExecutor,
29 ) -> Result<Vec<SessionResourceAllocation>> {
30 let TargetTemplate::AwsEc2 {
31 aws_profile,
32 region,
33 launch_template,
34 launch_template_version,
35 ..
36 } = self
37 .config
38 .targets
39 .get(target_id)
40 .with_context(|| format!("unknown target template {target_id:?}"))?
41 else {
42 bail!("target {target_id:?} is not an AWS EC2 target");
43 };
44 let profile = aws_profile.as_deref().unwrap_or("default");
45 let launch_key = if launch_template.starts_with("lt-") {
46 "--launch-template-id"
47 } else {
48 "--launch-template-name"
49 };
50 let version = launch_template_version.as_deref().unwrap_or("$Default");
51 let describe_template = CommandSpec::new(
52 "aws",
53 [
54 "--profile",
55 profile,
56 "--region",
57 region,
58 "ec2",
59 "describe-launch-template-versions",
60 launch_key,
61 launch_template,
62 "--versions",
63 version,
64 "--output",
65 "json",
66 ],
67 )
68 .purpose("resolve EC2 launch template instance family");
69 let output = executor.execute(&describe_template)?;
70 if output.status != 0 {
71 bail!(
72 "{} failed with status {}: {}",
73 describe_template.purpose,
74 output.status,
75 String::from_utf8_lossy(&output.stderr).trim()
76 );
77 }
78 let response: serde_json::Value =
79 serde_json::from_slice(&output.stdout).context("parse EC2 launch template response")?;
80 let instance_type = response
81 .pointer("/LaunchTemplateVersions/0/LaunchTemplateData/InstanceType")
82 .and_then(serde_json::Value::as_str)
83 .context("launch template does not specify a concrete instance type")?;
84 let family = instance_type
85 .rsplit_once('.')
86 .map(|(family, _)| family)
87 .context("launch template instance type has no size suffix")?;
88 let filter = format!("Name=instance-type,Values={family}.*");
89 let describe_types = CommandSpec::new(
90 "aws",
91 [
92 "--profile",
93 profile,
94 "--region",
95 region,
96 "ec2",
97 "describe-instance-types",
98 "--filters",
99 &filter,
100 "--output",
101 "json",
102 ],
103 )
104 .purpose("discover EC2 instance sizes");
105 let output = executor.execute(&describe_types)?;
106 if output.status != 0 {
107 bail!(
108 "{} failed with status {}: {}",
109 describe_types.purpose,
110 output.status,
111 String::from_utf8_lossy(&output.stderr).trim()
112 );
113 }
114 let response: serde_json::Value =
115 serde_json::from_slice(&output.stdout).context("parse EC2 instance type response")?;
116 let mut options = response
117 .get("InstanceTypes")
118 .and_then(serde_json::Value::as_array)
119 .context("EC2 instance type response omitted InstanceTypes")?
120 .iter()
121 .filter_map(|entry| {
122 Some(SessionResourceAllocation::AwsEc2 {
123 instance_type: entry.get("InstanceType")?.as_str()?.to_owned(),
124 vcpus: entry.pointer("/VCpuInfo/DefaultVCpus")?.as_u64()?,
125 memory_bytes: entry
126 .pointer("/MemoryInfo/SizeInMiB")?
127 .as_u64()?
128 .checked_mul(1024 * 1024)?,
129 })
130 })
131 .collect::<Vec<_>>();
132 options.sort_by_key(allocation_cpus);
133 if !options.iter().any(|option| allocation_cpus(option) == 8) {
134 bail!("EC2 family {family:?} has no exact 8-vCPU baseline size");
135 }
136 Ok(options)
137 }
138
139 pub fn reconnect_command(&self, session_id: &str) -> Result<CommandSpec> {
140 let session = self
141 .state
142 .sessions
143 .get(session_id)
144 .with_context(|| format!("unknown session {session_id}"))?;
145 let locator = session.target.as_ref().context("session has no target")?;
146 let backend = backend_locator(locator, session, &self.config)?;
147 hel_targets::reconnect_plan(&backend, session_id)?
148 .commands
149 .into_iter()
150 .next()
151 .context("reconnect plan is empty")
152 }
153
154 pub fn resource_probe(&self, session_id: &str) -> Result<hel_targets::SessionResourceProbe> {
155 let session = self
156 .state
157 .sessions
158 .get(session_id)
159 .with_context(|| format!("unknown session {session_id}"))?;
160 let locator = session.target.as_ref().context("session has no target")?;
161 let backend = backend_locator(locator, session, &self.config)?;
162 hel_targets::resource_probe(&backend, session_id)
163 }
164
165 pub fn deployment_capacity_targets(&self) -> Vec<hel_targets::DeploymentCapacityTarget> {
166 use hel_targets::{DeploymentCapacityKind, DeploymentCapacityTarget};
167
168 let mut local_ids = Vec::new();
169 let mut ssh_hosts: BTreeMap<String, (Vec<String>, Vec<CommandSpec>)> = BTreeMap::new();
170 let mut targets = Vec::new();
171 for (target_id, template) in &self.config.targets {
172 match template {
173 TargetTemplate::LocalBare
174 | TargetTemplate::LocalPodman { .. }
175 | TargetTemplate::LocalDocker { .. }
176 | TargetTemplate::AppleContainer { .. } => {
177 local_ids.push(target_id.clone());
178 }
179 TargetTemplate::SshBare { ssh, .. } | TargetTemplate::SshPodman { ssh, .. } => {
180 let entry = ssh_hosts.entry(ssh.host.clone()).or_default();
181 entry.0.push(target_id.clone());
182 let command = hel_targets::ssh_host_capacity_command(&backend_ssh(ssh));
183 if !entry.1.contains(&command) {
184 entry.1.push(command);
185 }
186 }
187 TargetTemplate::AwsEc2 { .. } => {
188 let mut probes = Vec::new();
189 let mut probe_error = None;
190 for session in self.state.sessions.values().filter(|session| {
191 session.target_template_id == *target_id
192 && session.state.is_active()
193 && session.target.is_some()
194 }) {
195 let result = backend_locator(
196 session.target.as_ref().expect("filtered target"),
197 session,
198 &self.config,
199 )
200 .and_then(|locator| {
201 hel_targets::aws_allocated_capacity_command(&locator, &session.id)
202 });
203 match result {
204 Ok(command) => probes.push(command),
205 Err(error) => probe_error = Some(format!("{error:#}")),
206 }
207 }
208 targets.push(DeploymentCapacityTarget {
209 id: format!("aws:{target_id}"),
210 host: target_id.clone(),
211 target_ids: vec![target_id.clone()],
212 kind: DeploymentCapacityKind::AwsFleet,
213 local: false,
214 probes,
215 probe_error,
216 });
217 }
218 }
219 }
220 if !local_ids.is_empty() {
221 targets.push(DeploymentCapacityTarget {
222 id: "local".into(),
223 host: "local".into(),
224 target_ids: local_ids,
225 kind: DeploymentCapacityKind::Host,
226 local: true,
227 probes: Vec::new(),
228 probe_error: None,
229 });
230 }
231 targets.extend(ssh_hosts.into_iter().map(|(host, (target_ids, probes))| {
232 DeploymentCapacityTarget {
233 id: format!("ssh:{host}"),
234 host,
235 target_ids,
236 kind: DeploymentCapacityKind::Host,
237 local: false,
238 probes,
239 probe_error: None,
240 }
241 }));
242 targets.sort_by(|left, right| left.id.cmp(&right.id));
243 targets
244 }
245
246 pub fn test_target(&self, target_id: &str, executor: &impl CommandExecutor) -> Result<()> {
247 let template = self
248 .config
249 .targets
250 .get(target_id)
251 .with_context(|| format!("unknown target template {target_id:?}"))?;
252 preflight_target(template, executor)
253 }
254}
255
256pub(super) fn preflight_target(
257 template: &TargetTemplate,
258 executor: &impl CommandExecutor,
259) -> Result<()> {
260 match template {
261 TargetTemplate::LocalPodman { .. } => hel_targets::verify_local_podman(executor)
262 .map(|_| ())
263 .map_err(|error| {
264 anyhow::anyhow!(
265 "local Podman preflight failed; run `mj doctor` for actionable prerequisites: {error:#}"
266 )
267 }),
268 TargetTemplate::LocalDocker { .. } => hel_targets::verify_local_docker(executor)
269 .map(|_| ())
270 .map_err(|error| {
271 anyhow::anyhow!(
272 "local Docker preflight failed; run `mj doctor` for actionable prerequisites: {error:#}"
273 )
274 }),
275 TargetTemplate::SshPodman { ssh, .. } => {
276 let ssh = backend_ssh(ssh);
277 hel_targets::verify_ssh_podman(&ssh, executor)
278 .map(|preflight| {
279 for warning in preflight.warnings {
280 executor.notify_notice(&warning.notice());
281 }
282 })
283 .map_err(|error| {
284 anyhow::anyhow!(
285 "remote Podman preflight failed for {}; run `mj doctor` for actionable prerequisites: {error:#}",
286 ssh.destination
287 )
288 })
289 }
290 TargetTemplate::AppleContainer { .. } => {
291 let command = CommandSpec::new("container", ["system", "status"])
292 .purpose("preflight Apple container runtime")
293 .stage(ProvisionStage::Provisioning);
294 let output = executor.execute(&command).map_err(|error| {
295 anyhow::anyhow!(
296 "Apple container preflight failed; run `mj doctor` for actionable prerequisites: {error}"
297 )
298 })?;
299 if output.status != 0 {
300 bail!(
301 "Apple container preflight failed; run `mj doctor` for actionable prerequisites: container system status exited {}: {}",
302 output.status,
303 String::from_utf8_lossy(&output.stderr).trim()
304 );
305 }
306 Ok(())
307 }
308 TargetTemplate::SshBare { ssh, .. } => {
309 let ssh = backend_ssh(ssh);
310 let command = hel_targets::ssh_connectivity_probe(&ssh);
311 let output = executor.execute(&command)?;
312 ensure!(
313 output.status == 0,
314 "SSH connectivity test failed for {} with status {}: {}",
315 ssh.destination,
316 output.status,
317 String::from_utf8_lossy(&output.stderr).trim()
318 );
319 Ok(())
320 }
321 TargetTemplate::AwsEc2 {
322 aws_profile,
323 region,
324 launch_template,
325 launch_template_version,
326 ..
327 } => {
328 let mut identity_args = vec!["sts".into(), "get-caller-identity".into()];
329 if let Some(profile) = aws_profile {
330 identity_args.extend(["--profile".into(), profile.clone()]);
331 }
332 let identity = CommandSpec::new("aws", identity_args)
333 .purpose("verify AWS credentials")
334 .stage(ProvisionStage::Provisioning);
335 let output = executor.execute(&identity)?;
336 ensure!(
337 output.status == 0,
338 "AWS credential test failed with status {}: {}",
339 output.status,
340 String::from_utf8_lossy(&output.stderr).trim()
341 );
342
343 let mut launch_args = vec![
344 "ec2".into(),
345 "describe-launch-template-versions".into(),
346 "--region".into(),
347 region.clone(),
348 "--launch-template-name".into(),
349 launch_template.clone(),
350 "--versions".into(),
351 launch_template_version
352 .clone()
353 .unwrap_or_else(|| "$Default".into()),
354 ];
355 if let Some(profile) = aws_profile {
356 launch_args.extend(["--profile".into(), profile.clone()]);
357 }
358 let launch = CommandSpec::new("aws", launch_args)
359 .purpose("verify AWS launch template")
360 .stage(ProvisionStage::Provisioning);
361 let output = executor.execute(&launch)?;
362 ensure!(
363 output.status == 0,
364 "AWS launch-template test failed with status {}: {}",
365 output.status,
366 String::from_utf8_lossy(&output.stderr).trim()
367 );
368 Ok(())
369 }
370 TargetTemplate::LocalBare => Ok(()),
371 }
372}
373
374pub(super) fn backend_bundle(bundle: &ProjectBundle) -> Result<ProjectBundleSpec> {
375 let primary = bundle.primary().context("bundle primary is missing")?;
376 Ok(ProjectBundleSpec {
377 primary: primary.destination.to_string_lossy().into_owned(),
378 repositories: bundle
379 .repositories
380 .iter()
381 .map(|repository| RepositorySpec {
382 url: repository.github.as_deref().map(github_url),
383 destination: repository.destination.to_string_lossy().into_owned(),
384 git_ref: repository.git_ref.clone(),
385 reference: None,
386 })
387 .collect(),
388 })
389}
390
391fn github_url(source: &str) -> String {
392 if source.contains("://") || source.starts_with("git@") {
393 source.to_string()
394 } else {
395 format!("https://github.com/{}.git", source.trim_end_matches(".git"))
396 }
397}
398
399#[derive(Debug, Clone, Copy, Default)]
403pub(super) struct ContainerOverrides<'a> {
404 pub cpus: Option<&'a str>,
405 pub memory: Option<&'a str>,
406}
407
408impl<'a> ContainerOverrides<'a> {
409 pub(super) fn for_session(session: &'a SessionRecord) -> Self {
410 Self {
411 cpus: session.container_cpus.as_deref(),
412 memory: session.container_memory.as_deref(),
413 }
414 }
415}
416
417pub(super) fn backend_target(
418 template: &TargetTemplate,
419 allocation: Option<&SessionResourceAllocation>,
420 overrides: ContainerOverrides<'_>,
421) -> Result<hel_targets::TargetTemplate> {
422 Ok(match template {
423 TargetTemplate::LocalBare => hel_targets::TargetTemplate::LocalBare,
424 TargetTemplate::LocalPodman { container } => {
425 let mut backend = backend_container(container, allocation, overrides);
426 backend.workspace_storage = backend_workspace_storage(&container.workspace_storage);
427 hel_targets::TargetTemplate::LocalPodman(backend)
428 }
429 TargetTemplate::LocalDocker { container } => hel_targets::TargetTemplate::LocalDocker(
430 backend_container(container, allocation, overrides),
431 ),
432 TargetTemplate::AppleContainer { container } => {
433 hel_targets::TargetTemplate::AppleContainer(backend_container(
434 container, allocation, overrides,
435 ))
436 }
437 TargetTemplate::AwsEc2 {
438 aws_profile,
439 region,
440 launch_template,
441 launch_template_version,
442 ssh_user,
443 identity_file,
444 ssh_args,
445 ..
446 } => hel_targets::TargetTemplate::AwsEc2(AwsTemplate {
447 profile: aws_profile.clone().unwrap_or_else(|| "default".into()),
448 region: region.clone(),
449 launch_template: launch_template.clone(),
450 launch_template_version: launch_template_version.clone(),
451 instance_type: match allocation {
452 Some(SessionResourceAllocation::AwsEc2 { instance_type, .. }) => {
453 Some(instance_type.clone())
454 }
455 _ => None,
456 },
457 ssh: SshTarget {
459 destination: format!("{ssh_user}@pending.invalid"),
460 ssh_args: ssh_args_with_identity(ssh_args, identity_file.as_deref()),
461 },
462 }),
463 TargetTemplate::SshBare {
464 ssh,
465 workspace_prefix,
466 ..
467 } => hel_targets::TargetTemplate::SshBare {
468 ssh: backend_ssh(ssh),
469 workspace_prefix: workspace_prefix.to_string_lossy().into_owned(),
470 },
471 TargetTemplate::SshPodman { ssh, container, .. } => {
472 let mut backend = backend_container(container, allocation, overrides);
473 backend.workspace_storage = backend_workspace_storage(&container.workspace_storage);
474 hel_targets::TargetTemplate::SshPodman {
475 ssh: backend_ssh(ssh),
476 container: backend,
477 }
478 }
479 })
480}
481
482pub fn image_refresh_plan(config: &HelConfig) -> Vec<ImageRefresh> {
490 let mut plan: Vec<ImageRefresh> = Vec::new();
491 for target in config.targets.values() {
492 let (host, container) = match target {
493 TargetTemplate::LocalPodman { container } => (ImageHost::LocalPodman, container),
494 TargetTemplate::LocalDocker { container } => (ImageHost::LocalDocker, container),
495 TargetTemplate::SshPodman { ssh, container } => {
496 (ImageHost::SshPodman(backend_ssh(ssh)), container)
497 }
498 TargetTemplate::LocalBare
499 | TargetTemplate::AppleContainer { .. }
500 | TargetTemplate::AwsEc2 { .. }
501 | TargetTemplate::SshBare { .. } => continue,
502 };
503 let Some(refresh) = hel_targets::image_refresh(
506 host,
507 &container.image,
508 container.platform.as_deref(),
509 container.pull_policy,
510 ) else {
511 continue;
512 };
513 if !plan.contains(&refresh) {
514 plan.push(refresh);
515 }
516 }
517 plan
518}
519
520pub(crate) fn controller_github_token() -> Option<String> {
521 for name in ["GH_TOKEN", "GITHUB_TOKEN"] {
522 if let Ok(token) = std::env::var(name)
523 && let Some(token) = usable_github_token(&token)
524 {
525 return Some(token.to_owned());
526 }
527 }
528 let output = match Command::new("gh")
529 .args(["auth", "token", "--hostname", "github.com"])
530 .stdin(Stdio::null())
531 .stderr(Stdio::null())
532 .output()
533 {
534 Ok(output) => output,
535 Err(error) => {
536 tracing::debug!(%error, "could not query the GitHub CLI for a token");
537 return None;
538 }
539 };
540 if !output.status.success() {
541 tracing::debug!(status = ?output.status, "GitHub CLI did not return an authenticated token");
542 return None;
543 }
544 let token = match std::str::from_utf8(&output.stdout) {
545 Ok(token) => token,
546 Err(error) => {
547 tracing::debug!(%error, "GitHub CLI returned a non-UTF-8 token");
548 return None;
549 }
550 };
551 let Some(token) = usable_github_token(token) else {
552 tracing::debug!("GitHub CLI returned an empty or invalid token");
553 return None;
554 };
555 Some(token.to_owned())
556}
557
558fn usable_github_token(token: &str) -> Option<&str> {
559 let token = token.trim();
560 (!token.is_empty() && !token.chars().any(char::is_whitespace)).then_some(token)
561}
562
563pub(super) fn configure_github_token_environment(target: &mut hel_targets::TargetTemplate) -> bool {
564 let container = match target {
565 hel_targets::TargetTemplate::LocalPodman(container)
566 | hel_targets::TargetTemplate::LocalDocker(container)
567 | hel_targets::TargetTemplate::AppleContainer(container)
568 | hel_targets::TargetTemplate::SshPodman { container, .. } => container,
569 hel_targets::TargetTemplate::LocalBare
570 | hel_targets::TargetTemplate::AwsEc2(_)
571 | hel_targets::TargetTemplate::SshBare { .. } => return false,
572 };
573 container
574 .extra_run_args
575 .extend(["--env".to_owned(), "GH_TOKEN".to_owned()]);
576 true
577}
578
579pub(super) fn use_github_https_urls(bundle: &mut hel_targets::ProjectBundleSpec) {
580 for repository in &mut bundle.repositories {
581 let Some(source) = repository.url.as_deref() else {
582 continue;
583 };
584 let Some(github) = crate::hel_setup::github_repository_from_origin(source) else {
585 continue;
586 };
587 repository.url = Some(format!(
588 "https://github.com/{}/{}.git",
589 github.owner, github.repository
590 ));
591 }
592}
593
594fn backend_container(
595 container: &hel::hel_config::ContainerTemplate,
596 allocation: Option<&SessionResourceAllocation>,
597 overrides: ContainerOverrides<'_>,
598) -> ContainerTemplate {
599 let mut extra_run_args = Vec::new();
600 if let Some(platform) = &container.platform {
601 extra_run_args.push(format!("--platform={platform}"));
602 }
603 let (cpus, memory) = match allocation {
604 Some(SessionResourceAllocation::Container { cpus, memory_bytes }) => {
605 (Some(cpus.to_string()), Some(memory_bytes.to_string()))
606 }
607 _ => (container.cpus.clone(), container.memory.clone()),
608 };
609 let cpus = overrides.cpus.map(str::to_owned).or(cpus);
611 let memory = overrides.memory.map(str::to_owned).or(memory);
612 if let Some(cpus) = cpus {
613 extra_run_args.push(format!("--cpus={cpus}"));
614 }
615 if let Some(memory) = memory {
616 extra_run_args.push(format!("--memory={memory}"));
617 }
618 for (key, value) in &container.environment {
619 extra_run_args.extend(["--env".to_string(), format!("{key}={value}")]);
620 }
621 ContainerTemplate {
622 image: container.image.clone(),
623 pull_policy: container.pull_policy,
624 extra_run_args,
625 workspace_storage: hel_targets::PodmanWorkspaceStorage::ContainerLayer,
626 }
627}
628
629fn backend_workspace_storage(
630 storage: &PodmanWorkspaceStorage,
631) -> hel_targets::PodmanWorkspaceStorage {
632 match storage {
633 PodmanWorkspaceStorage::PodmanVolume => hel_targets::PodmanWorkspaceStorage::PodmanVolume,
634 PodmanWorkspaceStorage::HostHelper { root, helper } => {
635 hel_targets::PodmanWorkspaceStorage::HostHelper {
636 root: root.to_string_lossy().into_owned(),
637 helper: helper.clone(),
638 }
639 }
640 PodmanWorkspaceStorage::ContainerLayer => {
641 hel_targets::PodmanWorkspaceStorage::ContainerLayer
642 }
643 }
644}
645
646fn backend_workspace_locator(
647 storage: &PodmanWorkspaceLocator,
648) -> hel_targets::PodmanWorkspaceLocator {
649 match storage {
650 PodmanWorkspaceLocator::ContainerLayer => {
651 hel_targets::PodmanWorkspaceLocator::ContainerLayer
652 }
653 PodmanWorkspaceLocator::Volume { name } => {
654 hel_targets::PodmanWorkspaceLocator::Volume { name: name.clone() }
655 }
656 PodmanWorkspaceLocator::HostPath {
657 path,
658 helper,
659 resource,
660 } => hel_targets::PodmanWorkspaceLocator::HostPath {
661 path: path.to_string_lossy().into_owned(),
662 helper: helper.clone(),
663 resource: resource.clone(),
664 },
665 }
666}
667
668fn durable_workspace_locator(
669 storage: hel_targets::PodmanWorkspaceLocator,
670) -> PodmanWorkspaceLocator {
671 match storage {
672 hel_targets::PodmanWorkspaceLocator::ContainerLayer => {
673 PodmanWorkspaceLocator::ContainerLayer
674 }
675 hel_targets::PodmanWorkspaceLocator::Volume { name } => {
676 PodmanWorkspaceLocator::Volume { name }
677 }
678 hel_targets::PodmanWorkspaceLocator::HostPath {
679 path,
680 helper,
681 resource,
682 } => PodmanWorkspaceLocator::HostPath {
683 path: PathBuf::from(path),
684 helper,
685 resource,
686 },
687 }
688}
689
690pub(super) fn validate_resource_allocation(
691 template: &TargetTemplate,
692 allocation: Option<&SessionResourceAllocation>,
693) -> Result<()> {
694 match (template, allocation) {
695 (_, None)
696 | (
697 TargetTemplate::LocalPodman { .. }
698 | TargetTemplate::LocalDocker { .. }
699 | TargetTemplate::AppleContainer { .. }
700 | TargetTemplate::SshPodman { .. },
701 Some(SessionResourceAllocation::Container { .. }),
702 )
703 | (TargetTemplate::AwsEc2 { .. }, Some(SessionResourceAllocation::AwsEc2 { .. })) => Ok(()),
704 (TargetTemplate::LocalBare | TargetTemplate::SshBare { .. }, Some(_)) => {
705 bail!("bare targets have fixed host resources")
706 }
707 _ => bail!("resource allocation does not match the selected target kind"),
708 }
709}
710
711const AWS_SSH_READY_TIMEOUT: Duration = Duration::from_secs(300);
713
714const AWS_SSH_READY_RETRY_DELAY: Duration = Duration::from_secs(3);
715
716fn wait_for_ssh_ready(
721 executor: &impl CommandExecutor,
722 probe: &CommandSpec,
723 timeout: Duration,
724 mut now: impl FnMut() -> Instant,
725 mut sleep: impl FnMut(Duration),
726) -> Result<()> {
727 let started = now();
728 loop {
729 if executor.cancellation_requested() {
730 bail!("cancelled while waiting for SSH on the new instance");
731 }
732 let failure = match executor.execute(probe) {
733 Ok(output) if output.status == 0 => return Ok(()),
734 Ok(output) => String::from_utf8_lossy(&output.stderr).trim().to_string(),
735 Err(error) => error.to_string(),
736 };
737 if now().duration_since(started) >= timeout {
738 bail!(
739 "{} timed out after {}s: {}",
740 probe.purpose,
741 timeout.as_secs(),
742 if failure.is_empty() {
743 "the SSH probe reported no error output"
744 } else {
745 failure.as_str()
746 }
747 );
748 }
749 sleep(AWS_SSH_READY_RETRY_DELAY);
750 }
751}
752
753pub(super) fn locator_after_provision(
754 canonical: &TargetTemplate,
755 backend: &hel_targets::TargetTemplate,
756 session_id: &str,
757 first_output: Option<&CommandOutput>,
758 executor: &(impl CommandExecutor + Sync),
759) -> Result<TargetLocator> {
760 let generated = hel_targets::resource_name(session_id)?;
761 Ok(match canonical {
762 TargetTemplate::LocalBare => TargetLocator::LocalBare {
763 worker_root: data_dir().join("workers").join(session_id),
764 },
765 TargetTemplate::LocalPodman { .. } => {
766 let hel_targets::TargetTemplate::LocalPodman(container) = backend else {
767 bail!("session locator/template mismatch")
768 };
769 TargetLocator::LocalPodman {
770 container_id: generated,
771 workspace_storage: durable_workspace_locator(
772 hel_targets::podman_workspace_locator(container, session_id)?,
773 ),
774 }
775 }
776 TargetTemplate::LocalDocker { .. } => TargetLocator::LocalDocker {
777 container_id: generated,
778 },
779 TargetTemplate::AppleContainer { .. } => TargetLocator::AppleContainer {
780 container_id: generated,
781 },
782 TargetTemplate::SshBare { ssh, .. } => TargetLocator::SshBare {
783 host: ssh.host.clone(),
784 workspace: PathBuf::from(hel_targets::workspace_for(backend, session_id)?),
785 worker_id: None,
786 },
787 TargetTemplate::SshPodman { ssh, .. } => {
788 let hel_targets::TargetTemplate::SshPodman { container, .. } = backend else {
789 bail!("session locator/template mismatch")
790 };
791 TargetLocator::SshPodman {
792 host: ssh.host.clone(),
793 container_id: generated,
794 workspace_storage: durable_workspace_locator(
795 hel_targets::podman_workspace_locator(container, session_id)?,
796 ),
797 }
798 }
799 TargetTemplate::AwsEc2 {
800 aws_profile,
801 region,
802 ssh_user,
803 address_source,
804 identity_file,
805 ssh_args,
806 ..
807 } => {
808 let output = first_output.context("AWS launch produced no output")?;
809 let json: serde_json::Value = serde_json::from_slice(&output.stdout)
810 .context("parse aws ec2 run-instances response")?;
811 let instance_id = json
812 .pointer("/Instances/0/InstanceId")
813 .and_then(serde_json::Value::as_str)
814 .context("AWS response omitted instance ID")?
815 .to_string();
816 let profile = aws_profile.clone().unwrap_or_else(|| "default".into());
817 execute_checked(
818 executor,
819 CommandSpec::new(
820 "aws",
821 [
822 "--profile".into(),
823 profile.clone(),
824 "--region".into(),
825 region.clone(),
826 "ec2".into(),
827 "wait".into(),
828 "instance-running".into(),
829 "--instance-ids".into(),
830 instance_id.clone(),
831 ],
832 )
833 .purpose("wait for EC2 session instance to run")
834 .stage(ProvisionStage::Booting),
835 )?;
836 let field = match address_source {
837 AwsAddressSource::PublicDns => "PublicDnsName",
838 AwsAddressSource::PublicIp => "PublicIpAddress",
839 AwsAddressSource::PrivateDns => "PrivateDnsName",
840 AwsAddressSource::PrivateIp => "PrivateIpAddress",
841 };
842 let address = execute_checked(
843 executor,
844 CommandSpec::new(
845 "aws",
846 [
847 "--profile".into(),
848 profile.clone(),
849 "--region".into(),
850 region.clone(),
851 "ec2".into(),
852 "describe-instances".into(),
853 "--instance-ids".into(),
854 instance_id.clone(),
855 "--query".into(),
856 format!("Reservations[0].Instances[0].{field}"),
857 "--output".into(),
858 "text".into(),
859 ],
860 )
861 .purpose("resolve EC2 session address")
862 .stage(ProvisionStage::Booting),
863 )?;
864 let address = String::from_utf8(address.stdout)
865 .context("AWS address was not UTF-8")?
866 .trim()
867 .to_string();
868 if address.is_empty() || address == "None" {
869 bail!("AWS instance {instance_id} has no configured address");
870 }
871 let ssh = SshTarget {
872 destination: format!("{ssh_user}@{address}"),
873 ssh_args: ssh_args_with_identity(ssh_args, identity_file.as_deref()),
874 };
875 wait_for_ssh_ready(
876 executor,
877 &ssh_command_spec(&ssh, ["true"])
878 .purpose("wait for EC2 SSH availability")
879 .stage(ProvisionStage::Booting),
880 AWS_SSH_READY_TIMEOUT,
881 Instant::now,
882 std::thread::sleep,
883 )?;
884 TargetLocator::AwsEc2 {
885 instance_id,
886 address: Some(address),
887 }
888 }
889 })
890}
891
892pub(super) fn backend_locator(
893 locator: &TargetLocator,
894 session: &SessionRecord,
895 config: &HelConfig,
896) -> Result<hel_targets::TargetLocator> {
897 let template = config
898 .targets
899 .get(&session.target_template_id)
900 .context("session target template is missing")?;
901 Ok(match locator {
902 TargetLocator::LocalBare { worker_root } => {
903 let TargetTemplate::LocalBare = template else {
904 bail!("session locator/template mismatch")
905 };
906 hel_targets::TargetLocator::LocalBare {
907 worker_root: worker_root.to_string_lossy().into_owned(),
908 }
909 }
910 TargetLocator::LocalPodman {
911 container_id,
912 workspace_storage,
913 } => hel_targets::TargetLocator::LocalPodman {
914 container_id: container_id.clone(),
915 workspace_storage: backend_workspace_locator(workspace_storage),
916 },
917 TargetLocator::LocalDocker { container_id } => hel_targets::TargetLocator::LocalDocker {
918 container_id: container_id.clone(),
919 },
920 TargetLocator::AppleContainer { container_id } => {
921 hel_targets::TargetLocator::AppleContainer {
922 container_id: container_id.clone(),
923 }
924 }
925 TargetLocator::SshBare { workspace, .. } => {
926 let TargetTemplate::SshBare { ssh, .. } = template else {
927 bail!("session locator/template mismatch")
928 };
929 hel_targets::TargetLocator::SshBare {
930 ssh: backend_ssh(ssh),
931 workspace: workspace.to_string_lossy().into_owned(),
932 }
933 }
934 TargetLocator::SshPodman {
935 container_id,
936 workspace_storage,
937 ..
938 } => {
939 let TargetTemplate::SshPodman { ssh, .. } = template else {
940 bail!("session locator/template mismatch")
941 };
942 hel_targets::TargetLocator::SshPodman {
943 ssh: backend_ssh(ssh),
944 container_id: container_id.clone(),
945 workspace_storage: backend_workspace_locator(workspace_storage),
946 }
947 }
948 TargetLocator::AwsEc2 {
949 instance_id,
950 address,
951 } => {
952 let TargetTemplate::AwsEc2 {
953 aws_profile,
954 region,
955 ssh_user,
956 identity_file,
957 ssh_args,
958 ..
959 } = template
960 else {
961 bail!("session locator/template mismatch")
962 };
963 let address = address.as_deref().context("AWS locator has no address")?;
964 hel_targets::TargetLocator::AwsEc2 {
965 profile: aws_profile.clone().unwrap_or_else(|| "default".into()),
966 region: region.clone(),
967 instance_id: instance_id.clone(),
968 ssh: SshTarget {
969 destination: format!("{ssh_user}@{address}"),
970 ssh_args: ssh_args_with_identity(ssh_args, identity_file.as_deref()),
971 },
972 workspace: format!(".local/share/hel/workspaces/{}", session.id),
973 }
974 }
975 })
976}
977
978pub(super) fn absolute_target_path(
979 executor: &impl CommandExecutor,
980 locator: &hel_targets::TargetLocator,
981 session_id: &str,
982 path: &str,
983) -> Result<String> {
984 if path.starts_with('/') {
985 return Ok(path.to_owned());
986 }
987 let output = execute_checked(
988 executor,
989 hel_targets::command_on_locator(
990 locator,
991 session_id,
992 vec!["pwd".into()],
993 "resolve target home directory",
994 )?,
995 )?;
996 let directory = String::from_utf8(output.stdout).context("decode target working directory")?;
997 let directory = directory.trim_end_matches(['\r', '\n', '/']);
998 if directory.is_empty() || !directory.starts_with('/') {
999 bail!("target returned an invalid working directory {directory:?}");
1000 }
1001 Ok(format!("{directory}/{path}"))
1002}
1003
1004#[cfg(test)]
1005mod tests {
1006 use std::cell::RefCell;
1007 use std::collections::BTreeMap;
1008 use std::path::PathBuf;
1009 use std::time::{Duration, Instant};
1010
1011 use anyhow::Result;
1012
1013 use crate::hel_controller::Controller;
1014 use crate::hel_controller::provisioning::install_attached_resources;
1015 use hel::hel_config::{
1016 AwsAddressSource, ContainerTemplate as ConfigContainer, HelConfig, ProjectBundle,
1017 ProjectRepository, SshConnection, TargetTemplate,
1018 };
1019 use hel::hel_state::{HelState, SessionRecord, SessionState};
1020 use hel::hel_targets::{
1021 self, AdditionalMount, CommandExecutor, CommandOutput, CommandSpec, ContainerTemplate,
1022 SshTarget,
1023 };
1024
1025 use super::*;
1026
1027 struct SshProbeExecutor {
1029 failures_remaining: RefCell<u32>,
1030 attempts: RefCell<u32>,
1031 cancel_after: Option<u32>,
1032 }
1033 impl SshProbeExecutor {
1034 fn new(failures: u32) -> Self {
1035 Self {
1036 failures_remaining: RefCell::new(failures),
1037 attempts: RefCell::new(0),
1038 cancel_after: None,
1039 }
1040 }
1041 }
1042 impl CommandExecutor for SshProbeExecutor {
1043 fn execute(&self, _command: &CommandSpec) -> Result<CommandOutput> {
1044 *self.attempts.borrow_mut() += 1;
1045 let mut remaining = self.failures_remaining.borrow_mut();
1046 if *remaining == 0 {
1047 return Ok(CommandOutput {
1048 status: 0,
1049 stdout: Vec::new(),
1050 stderr: Vec::new(),
1051 });
1052 }
1053 *remaining -= 1;
1054 Ok(CommandOutput {
1055 status: 255,
1056 stdout: Vec::new(),
1057 stderr: b"ssh: connect to host 10.0.0.1 port 22: Connection refused\n".to_vec(),
1058 })
1059 }
1060
1061 fn cancellation_requested(&self) -> bool {
1062 self.cancel_after
1063 .is_some_and(|limit| *self.attempts.borrow() >= limit)
1064 }
1065 }
1066 fn ssh_probe_spec() -> CommandSpec {
1067 CommandSpec::new("ssh", ["host", "true"]).purpose("wait for EC2 SSH availability")
1068 }
1069 fn virtual_clock() -> (std::rc::Rc<std::cell::Cell<Instant>>, Instant) {
1071 let start = Instant::now();
1072 (std::rc::Rc::new(std::cell::Cell::new(start)), start)
1073 }
1074 #[test]
1075 fn ssh_readiness_wait_succeeds_after_failed_probes() {
1076 let executor = SshProbeExecutor::new(3);
1077 let (clock, _) = virtual_clock();
1078 let sleep_clock = clock.clone();
1079 wait_for_ssh_ready(
1080 &executor,
1081 &ssh_probe_spec(),
1082 Duration::from_secs(300),
1083 {
1084 let clock = clock.clone();
1085 move || clock.get()
1086 },
1087 move |delay| sleep_clock.set(sleep_clock.get() + delay),
1088 )
1089 .expect("the wait succeeds once SSH answers");
1090 assert_eq!(*executor.attempts.borrow(), 4);
1091 }
1092 #[test]
1093 fn ssh_readiness_wait_gives_up_at_the_deadline_and_reports_the_last_error() {
1094 let executor = SshProbeExecutor::new(u32::MAX);
1095 let (clock, _) = virtual_clock();
1096 let sleep_clock = clock.clone();
1097 let error = wait_for_ssh_ready(
1098 &executor,
1099 &ssh_probe_spec(),
1100 Duration::from_secs(30),
1101 {
1102 let clock = clock.clone();
1103 move || clock.get()
1104 },
1105 move |delay| sleep_clock.set(sleep_clock.get() + delay),
1106 )
1107 .expect_err("the wait stops at the deadline");
1108 let message = error.to_string();
1109 assert!(message.contains("timed out after 30s"), "{message}");
1110 assert!(message.contains("Connection refused"), "{message}");
1111 }
1112 #[test]
1113 fn ssh_readiness_wait_stops_when_cancellation_is_requested() {
1114 let mut executor = SshProbeExecutor::new(u32::MAX);
1115 executor.cancel_after = Some(2);
1116 let (clock, _) = virtual_clock();
1117 let sleep_clock = clock.clone();
1118 let error = wait_for_ssh_ready(
1119 &executor,
1120 &ssh_probe_spec(),
1121 Duration::from_secs(300),
1122 {
1123 let clock = clock.clone();
1124 move || clock.get()
1125 },
1126 move |delay| sleep_clock.set(sleep_clock.get() + delay),
1127 )
1128 .expect_err("the wait stops when cancelled");
1129 assert!(error.to_string().contains("cancelled"), "{error}");
1130 assert_eq!(*executor.attempts.borrow(), 2);
1131 }
1132 #[test]
1133 fn aws_resources_are_compressed_into_one_streamed_ssh_command() {
1134 struct RecordingExecutor {
1135 commands: RefCell<Vec<CommandSpec>>,
1136 streams: RefCell<Vec<Vec<u8>>>,
1137 }
1138 impl CommandExecutor for RecordingExecutor {
1139 fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
1140 self.commands.borrow_mut().push(command.clone());
1141 Ok(CommandOutput {
1142 status: 0,
1143 stdout: Vec::new(),
1144 stderr: Vec::new(),
1145 })
1146 }
1147
1148 fn execute_with_stdin(
1149 &self,
1150 command: &CommandSpec,
1151 input: &mut (dyn std::io::Read + Send),
1152 ) -> Result<CommandOutput> {
1153 self.commands.borrow_mut().push(command.clone());
1154 let mut stream = Vec::new();
1155 input.read_to_end(&mut stream)?;
1156 self.streams.borrow_mut().push(stream);
1157 Ok(CommandOutput {
1158 status: 0,
1159 stdout: Vec::new(),
1160 stderr: Vec::new(),
1161 })
1162 }
1163 }
1164
1165 let source = tempfile::tempdir().unwrap();
1166 std::fs::create_dir_all(source.path().join("many/files")).unwrap();
1167 std::fs::write(source.path().join("many/files/one"), b"one").unwrap();
1168 std::fs::write(source.path().join("many/files/two"), b"two").unwrap();
1169 let session_id = "0123456789abcdef0123456789abcdef";
1170 let record = SessionRecord {
1171 workspace_id: hel::hel_workspace::DEFAULT_WORKSPACE_ID.to_owned(),
1172 archived: false,
1173 container_cpus: None,
1174 container_memory: None,
1175 id: session_id.into(),
1176 title: "AWS resources".into(),
1177 harness_kind: hel::hel_config::HarnessKind::Codex,
1178 last_profile: "codex".into(),
1179 bundle_id: "project".into(),
1180 project_directory: None,
1181 managed_worktree: None,
1182 target_template_id: "aws".into(),
1183 resource_allocation: None,
1184 additional_mounts: vec![AdditionalMount {
1185 source: source.path().to_path_buf(),
1186 destination: "/home/ubuntu/mj-resources/data".into(),
1187 read_only: false,
1188 }],
1189 state: SessionState::Disconnected,
1190 target: None,
1191 native_session_id: None,
1192 acp_session_title: None,
1193 session_title_override: None,
1194 created_at: "2026-08-12T00:00:00Z".into(),
1195 updated_at: "2026-08-12T00:00:00Z".into(),
1196 viewed_through_event_ordinal: 0,
1197 draft_input: String::new(),
1198 last_error: None,
1199 last_checkpoint_error: None,
1200 checkpoint: None,
1201 };
1202 let state = HelState {
1203 version: hel::hel_state::STATE_VERSION,
1204 sessions: BTreeMap::from([(session_id.into(), record)]),
1205 mount_history: BTreeMap::new(),
1206 container_sizes: BTreeMap::new(),
1207 };
1208 let backend = hel_targets::TargetLocator::AwsEc2 {
1209 profile: "default".into(),
1210 region: "us-east-1".into(),
1211 instance_id: "i-1234567890abcdef0".into(),
1212 ssh: SshTarget {
1213 destination: "ubuntu@example.test".into(),
1214 ssh_args: Vec::new(),
1215 },
1216 workspace: format!(".local/share/hel/workspaces/{session_id}"),
1217 };
1218 let executor = RecordingExecutor {
1219 commands: RefCell::new(Vec::new()),
1220 streams: RefCell::new(Vec::new()),
1221 };
1222
1223 install_attached_resources(
1224 &state,
1225 session_id,
1226 &backend,
1227 ".local/share/hel/workers/session",
1228 &executor,
1229 )
1230 .unwrap();
1231
1232 let commands = executor.commands.borrow();
1233 assert_eq!(commands.len(), 1);
1234 assert_eq!(commands[0].program, "ssh");
1235 assert!(
1236 commands[0]
1237 .args
1238 .iter()
1239 .any(|argument| argument.contains("install-resource"))
1240 );
1241 let streams = executor.streams.borrow();
1242 assert_eq!(streams.len(), 1);
1243 assert_eq!(&streams[0][..2], &[0x1f, 0x8b]);
1244 }
1245 #[test]
1246 fn canonical_bundle_maps_github_shorthand_and_primary_destination() {
1247 let bundle = ProjectBundle {
1248 primary_repo: "app".into(),
1249 repositories: vec![ProjectRepository {
1250 id: "app".into(),
1251 github: Some("example/app".into()),
1252 local: None,
1253 destination: PathBuf::from("services/app"),
1254 git_ref: Some("main".into()),
1255 }],
1256 };
1257 let backend = backend_bundle(&bundle).unwrap();
1258 assert_eq!(backend.primary, "services/app");
1259 assert_eq!(
1260 backend.repositories[0].url.as_deref(),
1261 Some("https://github.com/example/app.git")
1262 );
1263 }
1264 #[test]
1265 fn container_resources_and_environment_become_argv() {
1266 let template = TargetTemplate::LocalPodman {
1267 container: ConfigContainer {
1268 image: "dev:1".into(),
1269 pull_policy: hel::hel_config::ImagePullPolicy::Never,
1270 platform: Some("linux/arm64".into()),
1271 cpus: Some("4".into()),
1272 memory: Some("8g".into()),
1273 environment: std::collections::BTreeMap::from([("A".into(), "b c".into())]),
1274 workspace_storage: Default::default(),
1275 },
1276 };
1277 let hel_targets::TargetTemplate::LocalPodman(container) =
1278 backend_target(&template, None, ContainerOverrides::default()).unwrap()
1279 else {
1280 unreachable!()
1281 };
1282 assert!(container.extra_run_args.contains(&"--cpus=4".into()));
1283 assert!(container.extra_run_args.contains(&"A=b c".into()));
1284 assert_eq!(
1285 container.pull_policy,
1286 hel::hel_config::ImagePullPolicy::Never
1287 );
1288 }
1289 #[test]
1290 fn session_size_overrides_beat_the_target_template_and_its_allocation() {
1291 let template = TargetTemplate::LocalPodman {
1292 container: ConfigContainer {
1293 image: "dev:1".into(),
1294 pull_policy: Default::default(),
1295 platform: None,
1296 cpus: Some("4".into()),
1297 memory: Some("8g".into()),
1298 environment: std::collections::BTreeMap::new(),
1299 workspace_storage: Default::default(),
1300 },
1301 };
1302 let mut session =
1303 crate::hel_controller::test_support::checkpoint_test_session("session-size");
1304 session.container_cpus = Some("2".into());
1305 session.container_memory = Some("3g".into());
1306 session.resource_allocation = Some(SessionResourceAllocation::Container {
1307 cpus: 16,
1308 memory_bytes: 64_000_000_000,
1309 });
1310 let hel_targets::TargetTemplate::LocalPodman(container) = backend_target(
1311 &template,
1312 session.resource_allocation.as_ref(),
1313 ContainerOverrides::for_session(&session),
1314 )
1315 .unwrap() else {
1316 unreachable!()
1317 };
1318 assert!(container.extra_run_args.contains(&"--cpus=2".into()));
1319 assert!(container.extra_run_args.contains(&"--memory=3g".into()));
1320 assert!(!container.extra_run_args.iter().any(|argument| {
1321 argument.starts_with("--cpus=4")
1322 || argument.starts_with("--cpus=16")
1323 || argument.starts_with("--memory=8g")
1324 }));
1325 }
1326 #[test]
1327 fn github_token_is_inherited_only_by_managed_containers() {
1328 let mut podman = hel_targets::TargetTemplate::LocalPodman(ContainerTemplate {
1329 image: "dev:1".into(),
1330 pull_policy: Default::default(),
1331 extra_run_args: vec![],
1332 workspace_storage: Default::default(),
1333 });
1334 assert!(configure_github_token_environment(&mut podman));
1335 let hel_targets::TargetTemplate::LocalPodman(container) = podman else {
1336 unreachable!()
1337 };
1338 assert!(
1339 container
1340 .extra_run_args
1341 .windows(2)
1342 .any(|arguments| arguments == ["--env", "GH_TOKEN"])
1343 );
1344 assert!(
1345 !container
1346 .extra_run_args
1347 .iter()
1348 .any(|argument| argument.contains("github-token"))
1349 );
1350
1351 let mut bare = hel_targets::TargetTemplate::LocalBare;
1352 assert!(!configure_github_token_environment(&mut bare));
1353 assert_eq!(bare, hel_targets::TargetTemplate::LocalBare);
1354 assert_eq!(usable_github_token(" token-value\n"), Some("token-value"));
1355 assert_eq!(usable_github_token("not a token"), None);
1356
1357 let mut bundle = hel_targets::ProjectBundleSpec {
1358 primary: "app".into(),
1359 repositories: vec![hel_targets::RepositorySpec {
1360 url: Some("git@github.com:example/app.git".into()),
1361 destination: "app".into(),
1362 git_ref: None,
1363 reference: None,
1364 }],
1365 };
1366 use_github_https_urls(&mut bundle);
1367 assert_eq!(
1368 bundle.repositories[0].url.as_deref(),
1369 Some("https://github.com/example/app.git")
1370 );
1371 }
1372 fn container_target(
1373 image: &str,
1374 pull_policy: hel::hel_config::ImagePullPolicy,
1375 ) -> ConfigContainer {
1376 ConfigContainer {
1377 image: image.into(),
1378 pull_policy,
1379 platform: None,
1380 cpus: None,
1381 memory: None,
1382 environment: BTreeMap::new(),
1383 workspace_storage: Default::default(),
1384 }
1385 }
1386
1387 #[test]
1388 fn the_image_refresh_plan_covers_every_image_a_launch_no_longer_pulls() {
1389 use hel::hel_config::ImagePullPolicy;
1390
1391 let mut config = HelConfig::default();
1392 config.targets.insert(
1393 "podman".into(),
1394 TargetTemplate::LocalPodman {
1395 container: container_target("ghcr.io/example/dev:latest", ImagePullPolicy::Auto),
1396 },
1397 );
1398 config.targets.insert(
1400 "podman-again".into(),
1401 TargetTemplate::LocalPodman {
1402 container: container_target("ghcr.io/example/dev:latest", ImagePullPolicy::Auto),
1403 },
1404 );
1405 config.targets.insert(
1406 "ssh".into(),
1407 TargetTemplate::SshPodman {
1408 ssh: SshConnection {
1409 host: "builder.example.test".into(),
1410 user: Some("dev".into()),
1411 identity_file: Some(PathBuf::from("/home/dev/.ssh/builder")),
1412 extra_args: Vec::new(),
1413 },
1414 container: ConfigContainer {
1415 platform: Some("linux/amd64".into()),
1416 ..container_target("ghcr.io/example/dev:latest", ImagePullPolicy::Auto)
1417 },
1418 },
1419 );
1420 config.targets.insert(
1421 "docker".into(),
1422 TargetTemplate::LocalDocker {
1423 container: container_target("ghcr.io/example/dev:1.2.3", ImagePullPolicy::Newer),
1424 },
1425 );
1426 config.targets.insert(
1427 "pinned".into(),
1428 TargetTemplate::LocalPodman {
1429 container: container_target(
1430 "ghcr.io/example/dev@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
1431 ImagePullPolicy::Auto,
1432 ),
1433 },
1434 );
1435 config.targets.insert(
1437 "apple".into(),
1438 TargetTemplate::AppleContainer {
1439 container: container_target("ghcr.io/example/dev:latest", ImagePullPolicy::Auto),
1440 },
1441 );
1442
1443 let plan = image_refresh_plan(&config);
1444 assert_eq!(
1445 plan.len(),
1446 3,
1447 "expected one refresh per host and image: {plan:?}"
1448 );
1449
1450 let local = plan
1451 .iter()
1452 .find(|refresh| refresh.host == ImageHost::LocalPodman)
1453 .expect("the auto latest target is refreshed");
1454 assert_eq!(local.pull.program, "podman");
1455 assert_eq!(local.pull.args, ["pull", "ghcr.io/example/dev:latest"]);
1456 assert_eq!(local.prune.args, ["image", "prune", "-f"]);
1457 assert_eq!(
1458 local.image_id.args,
1459 [
1460 "image",
1461 "inspect",
1462 "--format",
1463 "{{.Id}}",
1464 "ghcr.io/example/dev:latest"
1465 ]
1466 );
1467
1468 let docker = plan
1469 .iter()
1470 .find(|refresh| refresh.host == ImageHost::LocalDocker)
1471 .expect("the explicit newer policy is refreshed too");
1472 assert_eq!(docker.pull.program, "docker");
1473 assert_eq!(docker.pull.args, ["pull", "ghcr.io/example/dev:1.2.3"]);
1474 assert_eq!(docker.prune.args, ["image", "prune", "-f"]);
1475
1476 let ssh = plan
1477 .iter()
1478 .find(|refresh| matches!(refresh.host, ImageHost::SshPodman(_)))
1479 .expect("the SSH host is refreshed over its own connection");
1480 assert_eq!(ssh.pull.program, "ssh");
1481 assert!(ssh.pull.args.contains(&"/home/dev/.ssh/builder".to_owned()));
1484 assert!(
1485 ssh.pull
1486 .args
1487 .contains(&"dev@builder.example.test".to_owned())
1488 );
1489 assert_eq!(
1490 ssh.pull.args.last().map(String::as_str),
1491 Some("'podman' 'pull' '--platform=linux/amd64' 'ghcr.io/example/dev:latest'")
1492 );
1493 assert_eq!(
1494 ssh.prune.args.last().map(String::as_str),
1495 Some("'podman' 'image' 'prune' '-f'")
1496 );
1497
1498 assert!(
1499 !plan.iter().any(|refresh| refresh.image.contains("sha256:")),
1500 "a digest-pinned image was refreshed: {plan:?}"
1501 );
1502 }
1503
1504 #[test]
1505 fn aws_resource_options_follow_the_launch_template_family() {
1506 let mut config = HelConfig::default();
1507 config.targets.insert(
1508 "aws".into(),
1509 TargetTemplate::AwsEc2 {
1510 aws_profile: None,
1511 region: "us-east-1".into(),
1512 launch_template: "hel-runson".into(),
1513 launch_template_version: None,
1514 ssh_user: "ubuntu".into(),
1515 address_source: AwsAddressSource::PublicIp,
1516 identity_file: None,
1517 ssh_args: Vec::new(),
1518 },
1519 );
1520 let executor = PreflightExecutor {
1521 outputs: RefCell::new(vec![
1522 CommandOutput {
1523 status: 0,
1524 stdout: br#"{"LaunchTemplateVersions":[{"LaunchTemplateData":{"InstanceType":"m8i-flex.large"}}]}"#.to_vec(),
1525 stderr: Vec::new(),
1526 },
1527 CommandOutput {
1528 status: 0,
1529 stdout: br#"{"InstanceTypes":[{"InstanceType":"m8i-flex.4xlarge","VCpuInfo":{"DefaultVCpus":16},"MemoryInfo":{"SizeInMiB":65536}},{"InstanceType":"m8i-flex.2xlarge","VCpuInfo":{"DefaultVCpus":8},"MemoryInfo":{"SizeInMiB":32768}}]}"#.to_vec(),
1530 stderr: Vec::new(),
1531 },
1532 ]),
1533 notices: RefCell::new(vec![]),
1534 };
1535 let controller = Controller {
1536 config,
1537 state: HelState::default(),
1538 };
1539
1540 let options = controller
1541 .resolve_aws_resource_options("aws", &executor)
1542 .unwrap();
1543 assert_eq!(
1544 options.iter().map(allocation_cpus).collect::<Vec<_>>(),
1545 [8, 16]
1546 );
1547 }
1548 #[test]
1549 fn deployment_capacity_groups_local_and_same_host_targets() {
1550 let container = || ConfigContainer {
1551 image: "dev:1".into(),
1552 pull_policy: Default::default(),
1553 platform: None,
1554 cpus: None,
1555 memory: None,
1556 environment: BTreeMap::new(),
1557 workspace_storage: Default::default(),
1558 };
1559 let ssh = |host: &str| SshConnection {
1560 host: host.into(),
1561 user: Some("builder".into()),
1562 identity_file: None,
1563 extra_args: Vec::new(),
1564 };
1565 let config = HelConfig {
1566 version: hel::hel_config::CONFIG_VERSION,
1567 newer_config_version: None,
1568 phone: Default::default(),
1569 review: Default::default(),
1570 profiles: BTreeMap::new(),
1571 bundles: BTreeMap::new(),
1572 targets: BTreeMap::from([
1573 (
1574 "apple".into(),
1575 TargetTemplate::AppleContainer {
1576 container: container(),
1577 },
1578 ),
1579 (
1580 "local".into(),
1581 TargetTemplate::LocalPodman {
1582 container: container(),
1583 },
1584 ),
1585 (
1586 "bare".into(),
1587 TargetTemplate::SshBare {
1588 ssh: ssh("builder"),
1589 permissions: hel::hel_config::PermissionMode::Yolo,
1590 workspace_prefix: ".local/share/hel/workspaces".into(),
1591 },
1592 ),
1593 (
1594 "remote-container".into(),
1595 TargetTemplate::SshPodman {
1596 ssh: ssh("builder"),
1597 container: container(),
1598 },
1599 ),
1600 (
1601 "alias".into(),
1602 TargetTemplate::SshBare {
1603 ssh: ssh("builder-alias"),
1604 permissions: hel::hel_config::PermissionMode::Yolo,
1605 workspace_prefix: ".local/share/hel/workspaces".into(),
1606 },
1607 ),
1608 ]),
1609 };
1610 let controller = Controller {
1611 config,
1612 state: HelState::default(),
1613 };
1614
1615 let targets = controller.deployment_capacity_targets();
1616
1617 assert_eq!(targets.len(), 3);
1618 let local = targets.iter().find(|target| target.id == "local").unwrap();
1619 assert_eq!(local.target_ids, ["apple", "local"]);
1620 let builder = targets
1621 .iter()
1622 .find(|target| target.id == "ssh:builder")
1623 .unwrap();
1624 assert_eq!(builder.target_ids, ["bare", "remote-container"]);
1625 assert_eq!(builder.probes.len(), 1);
1626 assert!(
1627 targets
1628 .iter()
1629 .any(|target| target.id == "ssh:builder-alias")
1630 );
1631 }
1632 struct PreflightExecutor {
1633 outputs: RefCell<Vec<CommandOutput>>,
1634 notices: RefCell<Vec<String>>,
1635 }
1636 impl CommandExecutor for PreflightExecutor {
1637 fn execute(&self, _command: &CommandSpec) -> Result<CommandOutput> {
1638 Ok(self.outputs.borrow_mut().remove(0))
1639 }
1640
1641 fn notify_notice(&self, notice: &str) {
1642 self.notices.borrow_mut().push(notice.to_owned());
1643 }
1644 }
1645 #[test]
1646 fn local_podman_preflight_failures_recommend_doctor() {
1647 let template = TargetTemplate::LocalPodman {
1648 container: ConfigContainer {
1649 image: "ubuntu:24.04".into(),
1650 pull_policy: Default::default(),
1651 platform: None,
1652 cpus: None,
1653 memory: None,
1654 environment: std::collections::BTreeMap::new(),
1655 workspace_storage: Default::default(),
1656 },
1657 };
1658 let executor = PreflightExecutor {
1659 outputs: RefCell::new(vec![CommandOutput {
1660 status: 0,
1661 stdout: b"podman version 3.4.7\n".to_vec(),
1662 stderr: vec![],
1663 }]),
1664 notices: RefCell::new(vec![]),
1665 };
1666
1667 let error = preflight_target(&template, &executor)
1668 .unwrap_err()
1669 .to_string();
1670 assert!(error.contains("mj doctor"));
1671 assert!(error.contains("Podman 4.0.0"));
1672 }
1673 #[test]
1674 fn ssh_podman_preflight_failures_name_the_destination_and_recommend_doctor() {
1675 let template = TargetTemplate::SshPodman {
1676 ssh: SshConnection {
1677 host: "example.test".into(),
1678 user: Some("dev".into()),
1679 identity_file: None,
1680 extra_args: vec![],
1681 },
1682 container: ConfigContainer {
1683 image: "ubuntu:24.04".into(),
1684 pull_policy: Default::default(),
1685 platform: None,
1686 cpus: None,
1687 memory: None,
1688 environment: std::collections::BTreeMap::new(),
1689 workspace_storage: Default::default(),
1690 },
1691 };
1692 let executor = PreflightExecutor {
1693 outputs: RefCell::new(vec![CommandOutput {
1694 status: 0,
1695 stdout: b"podman version 3.4.7\n".to_vec(),
1696 stderr: vec![],
1697 }]),
1698 notices: RefCell::new(vec![]),
1699 };
1700
1701 let error = preflight_target(&template, &executor)
1702 .unwrap_err()
1703 .to_string();
1704 assert!(error.contains("mj doctor"));
1705 assert!(error.contains("dev@example.test"));
1706 assert!(error.contains("Podman 4.0.0"));
1707 }
1708 #[test]
1709 fn ssh_podman_preflight_notifies_when_remote_user_lingering_is_disabled() {
1710 let template = TargetTemplate::SshPodman {
1711 ssh: SshConnection {
1712 host: "example.test".into(),
1713 user: Some("dev".into()),
1714 identity_file: None,
1715 extra_args: vec![],
1716 },
1717 container: ConfigContainer {
1718 image: "ubuntu:24.04".into(),
1719 pull_policy: Default::default(),
1720 platform: None,
1721 cpus: None,
1722 memory: None,
1723 environment: std::collections::BTreeMap::new(),
1724 workspace_storage: Default::default(),
1725 },
1726 };
1727 let executor = PreflightExecutor {
1728 outputs: RefCell::new(vec![
1729 CommandOutput {
1730 status: 0,
1731 stdout: b"podman version 5.4.2\n".to_vec(),
1732 stderr: vec![],
1733 },
1734 CommandOutput {
1735 status: 0,
1736 stdout: b"true\n".to_vec(),
1737 stderr: vec![],
1738 },
1739 CommandOutput {
1740 status: 0,
1741 stdout: b"0 1000 1\n1 100000 65536\n".to_vec(),
1742 stderr: vec![],
1743 },
1744 CommandOutput {
1745 status: 0,
1746 stdout: b"no\n".to_vec(),
1747 stderr: vec![],
1748 },
1749 ]),
1750 notices: RefCell::new(vec![]),
1751 };
1752
1753 preflight_target(&template, &executor).unwrap();
1754
1755 let notices = executor.notices.borrow();
1756 assert_eq!(notices.len(), 1);
1757 assert!(notices[0].contains("last SSH connection closes"));
1758 assert!(notices[0].contains("sudo loginctl enable-linger"));
1759 }
1760 #[test]
1761 fn apple_container_preflight_failures_recommend_doctor() {
1762 let template = TargetTemplate::AppleContainer {
1763 container: ConfigContainer {
1764 image: "ubuntu:24.04".into(),
1765 pull_policy: Default::default(),
1766 platform: None,
1767 cpus: None,
1768 memory: None,
1769 environment: std::collections::BTreeMap::new(),
1770 workspace_storage: Default::default(),
1771 },
1772 };
1773 let executor = PreflightExecutor {
1774 outputs: RefCell::new(vec![CommandOutput {
1775 status: 1,
1776 stdout: vec![],
1777 stderr: b"daemon is not running".to_vec(),
1778 }]),
1779 notices: RefCell::new(vec![]),
1780 };
1781
1782 let error = preflight_target(&template, &executor)
1783 .unwrap_err()
1784 .to_string();
1785 assert!(error.contains("mj doctor"));
1786 assert!(error.contains("daemon is not running"));
1787 }
1788}