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