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(
388 bundle: &ProjectBundle,
389 executor: &impl CommandExecutor,
390) -> Result<ProjectBundleSpec> {
391 let primary = bundle.primary().context("bundle primary is missing")?;
392 Ok(ProjectBundleSpec {
393 primary: primary.destination.to_string_lossy().into_owned(),
394 repositories: bundle
395 .repositories
396 .iter()
397 .map(|repository| {
398 let source = hel::hel_remote_git::resolve_repository(repository, executor)
399 .with_context(|| format!("repository {:?}", repository.id))?;
400 Ok(RepositorySpec {
401 url: Some(source.fetch_url),
402 push_urls: source.push_urls,
403 destination: repository.destination.to_string_lossy().into_owned(),
404 git_ref: None,
405 reference: None,
406 })
407 })
408 .collect::<Result<Vec<_>>>()?,
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 for source in repository
605 .url
606 .iter_mut()
607 .chain(repository.push_urls.iter_mut())
608 {
609 if let Some(github) = crate::hel_setup::github_repository_from_origin(source) {
610 *source = format!(
611 "https://github.com/{}/{}.git",
612 github.owner, github.repository
613 );
614 }
615 }
616 }
617}
618
619fn backend_container(
620 container: &hel::hel_config::ContainerTemplate,
621 allocation: Option<&SessionResourceAllocation>,
622 overrides: ContainerOverrides<'_>,
623) -> ContainerTemplate {
624 let mut extra_run_args = Vec::new();
625 if let Some(platform) = &container.platform {
626 extra_run_args.push(format!("--platform={platform}"));
627 }
628 let (cpus, memory) = match allocation {
629 Some(SessionResourceAllocation::Container { cpus, memory_bytes }) => {
630 (Some(cpus.to_string()), Some(memory_bytes.to_string()))
631 }
632 _ => (container.cpus.clone(), container.memory.clone()),
633 };
634 let cpus = overrides.cpus.map(str::to_owned).or(cpus);
636 let memory = overrides.memory.map(str::to_owned).or(memory);
637 if let Some(cpus) = cpus {
638 extra_run_args.push(format!("--cpus={cpus}"));
639 }
640 if let Some(memory) = memory {
641 extra_run_args.push(format!("--memory={memory}"));
642 }
643 for (key, value) in &container.environment {
644 extra_run_args.extend(["--env".to_string(), format!("{key}={value}")]);
645 }
646 ContainerTemplate {
647 image: container.image.clone(),
648 pull_policy: container.pull_policy,
649 extra_run_args,
650 workspace_storage: hel_targets::PodmanWorkspaceStorage::ContainerLayer,
651 }
652}
653
654fn backend_workspace_storage(
655 storage: &PodmanWorkspaceStorage,
656) -> hel_targets::PodmanWorkspaceStorage {
657 match storage {
658 PodmanWorkspaceStorage::PodmanVolume => hel_targets::PodmanWorkspaceStorage::PodmanVolume,
659 PodmanWorkspaceStorage::HostHelper { root, helper } => {
660 hel_targets::PodmanWorkspaceStorage::HostHelper {
661 root: root.to_string_lossy().into_owned(),
662 helper: helper.clone(),
663 }
664 }
665 PodmanWorkspaceStorage::ContainerLayer => {
666 hel_targets::PodmanWorkspaceStorage::ContainerLayer
667 }
668 }
669}
670
671fn backend_workspace_locator(
672 storage: &PodmanWorkspaceLocator,
673) -> hel_targets::PodmanWorkspaceLocator {
674 match storage {
675 PodmanWorkspaceLocator::ContainerLayer => {
676 hel_targets::PodmanWorkspaceLocator::ContainerLayer
677 }
678 PodmanWorkspaceLocator::Volume { name } => {
679 hel_targets::PodmanWorkspaceLocator::Volume { name: name.clone() }
680 }
681 PodmanWorkspaceLocator::HostPath {
682 path,
683 helper,
684 resource,
685 } => hel_targets::PodmanWorkspaceLocator::HostPath {
686 path: path.to_string_lossy().into_owned(),
687 helper: helper.clone(),
688 resource: resource.clone(),
689 },
690 }
691}
692
693fn durable_workspace_locator(
694 storage: hel_targets::PodmanWorkspaceLocator,
695) -> PodmanWorkspaceLocator {
696 match storage {
697 hel_targets::PodmanWorkspaceLocator::ContainerLayer => {
698 PodmanWorkspaceLocator::ContainerLayer
699 }
700 hel_targets::PodmanWorkspaceLocator::Volume { name } => {
701 PodmanWorkspaceLocator::Volume { name }
702 }
703 hel_targets::PodmanWorkspaceLocator::HostPath {
704 path,
705 helper,
706 resource,
707 } => PodmanWorkspaceLocator::HostPath {
708 path: PathBuf::from(path),
709 helper,
710 resource,
711 },
712 }
713}
714
715pub(super) fn validate_resource_allocation(
716 template: &TargetTemplate,
717 allocation: Option<&SessionResourceAllocation>,
718) -> Result<()> {
719 if let Some(allocation) = allocation {
720 allocation.validate()?;
721 }
722 match (template, allocation) {
723 (_, None)
724 | (
725 TargetTemplate::LocalPodman { .. }
726 | TargetTemplate::LocalDocker { .. }
727 | TargetTemplate::AppleContainer { .. }
728 | TargetTemplate::SshPodman { .. }
729 | TargetTemplate::SshDocker { .. },
730 Some(SessionResourceAllocation::Container { .. }),
731 )
732 | (TargetTemplate::AwsEc2 { .. }, Some(SessionResourceAllocation::AwsEc2 { .. })) => Ok(()),
733 (TargetTemplate::LocalBare | TargetTemplate::SshBare { .. }, Some(_)) => {
734 bail!("bare targets have fixed host resources")
735 }
736 _ => bail!("resource allocation does not match the selected target kind"),
737 }
738}
739
740const AWS_SSH_READY_TIMEOUT: Duration = Duration::from_secs(300);
742
743const AWS_SSH_READY_RETRY_DELAY: Duration = Duration::from_secs(3);
744
745fn wait_for_ssh_ready(
750 executor: &impl CommandExecutor,
751 probe: &CommandSpec,
752 timeout: Duration,
753 mut now: impl FnMut() -> Instant,
754 mut sleep: impl FnMut(Duration),
755) -> Result<()> {
756 let started = now();
757 loop {
758 if executor.cancellation_requested() {
759 bail!("cancelled while waiting for SSH on the new instance");
760 }
761 let failure = match executor.execute(probe) {
762 Ok(output) if output.status == 0 => return Ok(()),
763 Ok(output) => String::from_utf8_lossy(&output.stderr).trim().to_string(),
764 Err(error) => error.to_string(),
765 };
766 if now().duration_since(started) >= timeout {
767 bail!(
768 "{} timed out after {}s: {}",
769 probe.purpose,
770 timeout.as_secs(),
771 if failure.is_empty() {
772 "the SSH probe reported no error output"
773 } else {
774 failure.as_str()
775 }
776 );
777 }
778 sleep(AWS_SSH_READY_RETRY_DELAY);
779 }
780}
781
782pub(super) fn locator_after_provision(
783 canonical: &TargetTemplate,
784 backend: &hel_targets::TargetTemplate,
785 session_id: &str,
786 first_output: Option<&CommandOutput>,
787 executor: &(impl CommandExecutor + Sync),
788) -> Result<TargetLocator> {
789 let generated = hel_targets::resource_name(session_id)?;
790 Ok(match canonical {
791 TargetTemplate::LocalBare => TargetLocator::LocalBare {
792 worker_root: data_dir().join("workers").join(session_id),
793 },
794 TargetTemplate::LocalPodman { .. } => {
795 let hel_targets::TargetTemplate::LocalPodman(container) = backend else {
796 bail!("session locator/template mismatch")
797 };
798 TargetLocator::LocalPodman {
799 container_id: generated,
800 workspace_storage: durable_workspace_locator(
801 hel_targets::podman_workspace_locator(container, session_id)?,
802 ),
803 }
804 }
805 TargetTemplate::LocalDocker { .. } => TargetLocator::LocalDocker {
806 container_id: generated,
807 },
808 TargetTemplate::AppleContainer { .. } => TargetLocator::AppleContainer {
809 container_id: generated,
810 },
811 TargetTemplate::SshBare { ssh, .. } => TargetLocator::SshBare {
812 host: ssh.host.clone(),
813 workspace: PathBuf::from(hel_targets::workspace_for(backend, session_id)?),
814 worker_id: None,
815 },
816 TargetTemplate::SshPodman { ssh, .. } => {
817 let hel_targets::TargetTemplate::SshPodman { container, .. } = backend else {
818 bail!("session locator/template mismatch")
819 };
820 TargetLocator::SshPodman {
821 host: ssh.host.clone(),
822 container_id: generated,
823 workspace_storage: durable_workspace_locator(
824 hel_targets::podman_workspace_locator(container, session_id)?,
825 ),
826 }
827 }
828 TargetTemplate::SshDocker { ssh, .. } => TargetLocator::SshDocker {
829 host: ssh.host.clone(),
830 container_id: generated,
831 },
832 TargetTemplate::AwsEc2 {
833 aws_profile,
834 region,
835 ssh_user,
836 address_source,
837 identity_file,
838 ssh_args,
839 ..
840 } => {
841 let output = first_output.context("AWS launch produced no output")?;
842 let json: serde_json::Value = serde_json::from_slice(&output.stdout)
843 .context("parse aws ec2 run-instances response")?;
844 let instance_id = json
845 .pointer("/Instances/0/InstanceId")
846 .and_then(serde_json::Value::as_str)
847 .context("AWS response omitted instance ID")?
848 .to_string();
849 let profile = aws_profile.clone().unwrap_or_else(|| "default".into());
850 execute_checked(
851 executor,
852 CommandSpec::new(
853 "aws",
854 [
855 "--profile".into(),
856 profile.clone(),
857 "--region".into(),
858 region.clone(),
859 "ec2".into(),
860 "wait".into(),
861 "instance-running".into(),
862 "--instance-ids".into(),
863 instance_id.clone(),
864 ],
865 )
866 .purpose("wait for EC2 session instance to run")
867 .stage(ProvisionStage::Booting),
868 )?;
869 let field = match address_source {
870 AwsAddressSource::PublicDns => "PublicDnsName",
871 AwsAddressSource::PublicIp => "PublicIpAddress",
872 AwsAddressSource::PrivateDns => "PrivateDnsName",
873 AwsAddressSource::PrivateIp => "PrivateIpAddress",
874 };
875 let address = execute_checked(
876 executor,
877 CommandSpec::new(
878 "aws",
879 [
880 "--profile".into(),
881 profile.clone(),
882 "--region".into(),
883 region.clone(),
884 "ec2".into(),
885 "describe-instances".into(),
886 "--instance-ids".into(),
887 instance_id.clone(),
888 "--query".into(),
889 format!("Reservations[0].Instances[0].{field}"),
890 "--output".into(),
891 "text".into(),
892 ],
893 )
894 .purpose("resolve EC2 session address")
895 .stage(ProvisionStage::Booting),
896 )?;
897 let address = String::from_utf8(address.stdout)
898 .context("AWS address was not UTF-8")?
899 .trim()
900 .to_string();
901 if address.is_empty() || address == "None" {
902 bail!("AWS instance {instance_id} has no configured address");
903 }
904 let ssh = SshTarget {
905 destination: format!("{ssh_user}@{address}"),
906 ssh_args: ssh_args_with_identity(ssh_args, identity_file.as_deref()),
907 };
908 wait_for_ssh_ready(
909 executor,
910 &ssh_command_spec(&ssh, ["true"])
911 .purpose("wait for EC2 SSH availability")
912 .stage(ProvisionStage::Booting),
913 AWS_SSH_READY_TIMEOUT,
914 Instant::now,
915 std::thread::sleep,
916 )?;
917 TargetLocator::AwsEc2 {
918 instance_id,
919 address: Some(address),
920 }
921 }
922 })
923}
924
925pub(super) fn backend_locator(
926 locator: &TargetLocator,
927 session: &SessionRecord,
928 config: &HelConfig,
929) -> Result<hel_targets::TargetLocator> {
930 let template = config
931 .targets
932 .get(&session.target_template_id)
933 .context("session target template is missing")?;
934 Ok(match locator {
935 TargetLocator::LocalBare { worker_root } => {
936 let TargetTemplate::LocalBare = template else {
937 bail!("session locator/template mismatch")
938 };
939 hel_targets::TargetLocator::LocalBare {
940 worker_root: worker_root.to_string_lossy().into_owned(),
941 }
942 }
943 TargetLocator::LocalPodman {
944 container_id,
945 workspace_storage,
946 } => hel_targets::TargetLocator::LocalPodman {
947 container_id: container_id.clone(),
948 workspace_storage: backend_workspace_locator(workspace_storage),
949 },
950 TargetLocator::LocalDocker { container_id } => hel_targets::TargetLocator::LocalDocker {
951 container_id: container_id.clone(),
952 },
953 TargetLocator::AppleContainer { container_id } => {
954 hel_targets::TargetLocator::AppleContainer {
955 container_id: container_id.clone(),
956 }
957 }
958 TargetLocator::SshBare { workspace, .. } => {
959 let TargetTemplate::SshBare { ssh, .. } = template else {
960 bail!("session locator/template mismatch")
961 };
962 hel_targets::TargetLocator::SshBare {
963 ssh: backend_ssh(ssh),
964 workspace: workspace.to_string_lossy().into_owned(),
965 }
966 }
967 TargetLocator::SshPodman {
968 container_id,
969 workspace_storage,
970 ..
971 } => {
972 let TargetTemplate::SshPodman { ssh, .. } = template else {
973 bail!("session locator/template mismatch")
974 };
975 hel_targets::TargetLocator::SshPodman {
976 ssh: backend_ssh(ssh),
977 container_id: container_id.clone(),
978 workspace_storage: backend_workspace_locator(workspace_storage),
979 }
980 }
981 TargetLocator::SshDocker { host, container_id } => {
982 let TargetTemplate::SshDocker { ssh, .. } = template else {
983 bail!("session locator/template mismatch")
984 };
985 ensure!(
986 host == &ssh.host,
987 "session locator/template SSH host mismatch"
988 );
989 hel_targets::TargetLocator::SshDocker {
990 ssh: backend_ssh(ssh),
991 container_id: container_id.clone(),
992 }
993 }
994 TargetLocator::AwsEc2 {
995 instance_id,
996 address,
997 } => {
998 let TargetTemplate::AwsEc2 {
999 aws_profile,
1000 region,
1001 ssh_user,
1002 identity_file,
1003 ssh_args,
1004 ..
1005 } = template
1006 else {
1007 bail!("session locator/template mismatch")
1008 };
1009 let address = address.as_deref().context("AWS locator has no address")?;
1010 hel_targets::TargetLocator::AwsEc2 {
1011 profile: aws_profile.clone().unwrap_or_else(|| "default".into()),
1012 region: region.clone(),
1013 instance_id: instance_id.clone(),
1014 ssh: SshTarget {
1015 destination: format!("{ssh_user}@{address}"),
1016 ssh_args: ssh_args_with_identity(ssh_args, identity_file.as_deref()),
1017 },
1018 workspace: format!(".local/share/hel/workspaces/{}", session.id),
1019 }
1020 }
1021 })
1022}
1023
1024#[cfg(test)]
1025mod tests {
1026 use std::cell::RefCell;
1027 use std::collections::BTreeMap;
1028 use std::path::PathBuf;
1029 use std::time::{Duration, Instant};
1030
1031 use anyhow::Result;
1032
1033 use crate::hel_controller::Controller;
1034 use crate::hel_controller::provisioning::install_attached_resources;
1035 use hel::hel_config::{
1036 AwsAddressSource, ContainerTemplate as ConfigContainer, HelConfig, ProjectBundle,
1037 ProjectRepository, SshConnection, TargetTemplate,
1038 };
1039 use hel::hel_state::{HelState, SessionRecord, SessionState};
1040 use hel::hel_targets::{
1041 self, AdditionalMount, CommandExecutor, CommandOutput, CommandSpec, ContainerTemplate,
1042 SshTarget,
1043 };
1044
1045 use super::*;
1046
1047 struct SshProbeExecutor {
1049 failures_remaining: RefCell<u32>,
1050 attempts: RefCell<u32>,
1051 cancel_after: Option<u32>,
1052 }
1053 impl SshProbeExecutor {
1054 fn new(failures: u32) -> Self {
1055 Self {
1056 failures_remaining: RefCell::new(failures),
1057 attempts: RefCell::new(0),
1058 cancel_after: None,
1059 }
1060 }
1061 }
1062 impl CommandExecutor for SshProbeExecutor {
1063 fn execute(&self, _command: &CommandSpec) -> Result<CommandOutput> {
1064 *self.attempts.borrow_mut() += 1;
1065 let mut remaining = self.failures_remaining.borrow_mut();
1066 if *remaining == 0 {
1067 return Ok(CommandOutput {
1068 status: 0,
1069 stdout: Vec::new(),
1070 stderr: Vec::new(),
1071 });
1072 }
1073 *remaining -= 1;
1074 Ok(CommandOutput {
1075 status: 255,
1076 stdout: Vec::new(),
1077 stderr: b"ssh: connect to host 10.0.0.1 port 22: Connection refused\n".to_vec(),
1078 })
1079 }
1080
1081 fn cancellation_requested(&self) -> bool {
1082 self.cancel_after
1083 .is_some_and(|limit| *self.attempts.borrow() >= limit)
1084 }
1085 }
1086 fn ssh_probe_spec() -> CommandSpec {
1087 CommandSpec::new("ssh", ["host", "true"]).purpose("wait for EC2 SSH availability")
1088 }
1089 fn virtual_clock() -> (std::rc::Rc<std::cell::Cell<Instant>>, Instant) {
1091 let start = Instant::now();
1092 (std::rc::Rc::new(std::cell::Cell::new(start)), start)
1093 }
1094 #[test]
1095 fn ssh_readiness_wait_succeeds_after_failed_probes() {
1096 let executor = SshProbeExecutor::new(3);
1097 let (clock, _) = virtual_clock();
1098 let sleep_clock = clock.clone();
1099 wait_for_ssh_ready(
1100 &executor,
1101 &ssh_probe_spec(),
1102 Duration::from_secs(300),
1103 {
1104 let clock = clock.clone();
1105 move || clock.get()
1106 },
1107 move |delay| sleep_clock.set(sleep_clock.get() + delay),
1108 )
1109 .expect("the wait succeeds once SSH answers");
1110 assert_eq!(*executor.attempts.borrow(), 4);
1111 }
1112 #[test]
1113 fn ssh_readiness_wait_gives_up_at_the_deadline_and_reports_the_last_error() {
1114 let executor = SshProbeExecutor::new(u32::MAX);
1115 let (clock, _) = virtual_clock();
1116 let sleep_clock = clock.clone();
1117 let error = wait_for_ssh_ready(
1118 &executor,
1119 &ssh_probe_spec(),
1120 Duration::from_secs(30),
1121 {
1122 let clock = clock.clone();
1123 move || clock.get()
1124 },
1125 move |delay| sleep_clock.set(sleep_clock.get() + delay),
1126 )
1127 .expect_err("the wait stops at the deadline");
1128 let message = error.to_string();
1129 assert!(message.contains("timed out after 30s"), "{message}");
1130 assert!(message.contains("Connection refused"), "{message}");
1131 }
1132 #[test]
1133 fn ssh_readiness_wait_stops_when_cancellation_is_requested() {
1134 let mut executor = SshProbeExecutor::new(u32::MAX);
1135 executor.cancel_after = Some(2);
1136 let (clock, _) = virtual_clock();
1137 let sleep_clock = clock.clone();
1138 let error = wait_for_ssh_ready(
1139 &executor,
1140 &ssh_probe_spec(),
1141 Duration::from_secs(300),
1142 {
1143 let clock = clock.clone();
1144 move || clock.get()
1145 },
1146 move |delay| sleep_clock.set(sleep_clock.get() + delay),
1147 )
1148 .expect_err("the wait stops when cancelled");
1149 assert!(error.to_string().contains("cancelled"), "{error}");
1150 assert_eq!(*executor.attempts.borrow(), 2);
1151 }
1152 #[test]
1153 fn aws_resources_are_compressed_into_one_streamed_ssh_command() {
1154 struct RecordingExecutor {
1155 commands: RefCell<Vec<CommandSpec>>,
1156 streams: RefCell<Vec<Vec<u8>>>,
1157 }
1158 impl CommandExecutor for RecordingExecutor {
1159 fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
1160 self.commands.borrow_mut().push(command.clone());
1161 Ok(CommandOutput {
1162 status: 0,
1163 stdout: Vec::new(),
1164 stderr: Vec::new(),
1165 })
1166 }
1167
1168 fn execute_with_stdin(
1169 &self,
1170 command: &CommandSpec,
1171 input: &mut (dyn std::io::Read + Send),
1172 ) -> Result<CommandOutput> {
1173 self.commands.borrow_mut().push(command.clone());
1174 let mut stream = Vec::new();
1175 input.read_to_end(&mut stream)?;
1176 self.streams.borrow_mut().push(stream);
1177 Ok(CommandOutput {
1178 status: 0,
1179 stdout: Vec::new(),
1180 stderr: Vec::new(),
1181 })
1182 }
1183 }
1184
1185 let source = tempfile::tempdir().unwrap();
1186 std::fs::create_dir_all(source.path().join("many/files")).unwrap();
1187 std::fs::write(source.path().join("many/files/one"), b"one").unwrap();
1188 std::fs::write(source.path().join("many/files/two"), b"two").unwrap();
1189 let session_id = "0123456789abcdef0123456789abcdef";
1190 let record = SessionRecord {
1191 workspace_id: hel::hel_workspace::DEFAULT_WORKSPACE_ID.to_owned(),
1192 archived: false,
1193 container_cpus: None,
1194 container_memory: None,
1195 id: session_id.into(),
1196 title: "AWS resources".into(),
1197 harness_kind: hel::hel_config::HarnessKind::Codex,
1198 last_profile: "codex".into(),
1199 bundle_id: "project".into(),
1200 project_directory: None,
1201 managed_worktree: None,
1202 target_template_id: "aws".into(),
1203 resource_allocation: None,
1204 additional_mounts: vec![AdditionalMount {
1205 source: source.path().to_path_buf(),
1206 destination: "/home/ubuntu/mj-resources/data".into(),
1207 read_only: false,
1208 }],
1209 state: SessionState::Disconnected,
1210 target: None,
1211 native_session_id: None,
1212 acp_session_title: None,
1213 session_title_override: None,
1214 created_at: "2026-08-12T00:00:00Z".into(),
1215 updated_at: "2026-08-12T00:00:00Z".into(),
1216 viewed_through_event_ordinal: 0,
1217 draft_input: String::new(),
1218 last_error: None,
1219 last_checkpoint_error: None,
1220 checkpoint: None,
1221 };
1222 let state = HelState {
1223 version: hel::hel_state::STATE_VERSION,
1224 sessions: BTreeMap::from([(session_id.into(), record)]),
1225 mount_history: BTreeMap::new(),
1226 container_sizes: BTreeMap::new(),
1227 };
1228 let backend = hel_targets::TargetLocator::AwsEc2 {
1229 profile: "default".into(),
1230 region: "us-east-1".into(),
1231 instance_id: "i-1234567890abcdef0".into(),
1232 ssh: SshTarget {
1233 destination: "ubuntu@example.test".into(),
1234 ssh_args: Vec::new(),
1235 },
1236 workspace: format!(".local/share/hel/workspaces/{session_id}"),
1237 };
1238 let executor = RecordingExecutor {
1239 commands: RefCell::new(Vec::new()),
1240 streams: RefCell::new(Vec::new()),
1241 };
1242
1243 install_attached_resources(
1244 &state,
1245 session_id,
1246 &backend,
1247 ".local/share/hel/workers/session",
1248 &executor,
1249 )
1250 .unwrap();
1251
1252 let commands = executor.commands.borrow();
1253 assert_eq!(commands.len(), 1);
1254 assert_eq!(commands[0].program, "ssh");
1255 assert!(
1256 commands[0]
1257 .args
1258 .iter()
1259 .any(|argument| argument.contains("install-resource"))
1260 );
1261 let streams = executor.streams.borrow();
1262 assert_eq!(streams.len(), 1);
1263 assert_eq!(&streams[0][..2], &[0x1f, 0x8b]);
1264 }
1265 #[test]
1266 fn canonical_bundle_maps_github_shorthand_and_primary_destination() {
1267 let bundle = ProjectBundle {
1268 primary_repo: "app".into(),
1269 repositories: vec![ProjectRepository {
1270 id: "app".into(),
1271 github: Some("example/app".into()),
1272 local: None,
1273 destination: PathBuf::from("services/app"),
1274 git_ref: None,
1275 }],
1276 };
1277 let backend = backend_bundle(&bundle, &hel_targets::ProcessExecutor).unwrap();
1278 assert_eq!(backend.primary, "services/app");
1279 assert_eq!(
1280 backend.repositories[0].url.as_deref(),
1281 Some("https://github.com/example/app.git")
1282 );
1283 }
1284 #[test]
1285 fn container_resources_and_environment_become_argv() {
1286 let template = TargetTemplate::LocalPodman {
1287 container: ConfigContainer {
1288 image: "dev:1".into(),
1289 pull_policy: hel::hel_config::ImagePullPolicy::Never,
1290 platform: Some("linux/arm64".into()),
1291 cpus: Some("4".into()),
1292 memory: Some("8g".into()),
1293 environment: std::collections::BTreeMap::from([("A".into(), "b c".into())]),
1294 workspace_storage: Default::default(),
1295 },
1296 };
1297 let hel_targets::TargetTemplate::LocalPodman(container) =
1298 backend_target(&template, None, ContainerOverrides::default()).unwrap()
1299 else {
1300 unreachable!()
1301 };
1302 assert!(container.extra_run_args.contains(&"--cpus=4".into()));
1303 assert!(container.extra_run_args.contains(&"A=b c".into()));
1304 assert_eq!(
1305 container.pull_policy,
1306 hel::hel_config::ImagePullPolicy::Never
1307 );
1308 }
1309 #[test]
1310 fn session_size_overrides_beat_the_target_template_and_its_allocation() {
1311 let template = TargetTemplate::LocalPodman {
1312 container: ConfigContainer {
1313 image: "dev:1".into(),
1314 pull_policy: Default::default(),
1315 platform: None,
1316 cpus: Some("4".into()),
1317 memory: Some("8g".into()),
1318 environment: std::collections::BTreeMap::new(),
1319 workspace_storage: Default::default(),
1320 },
1321 };
1322 let mut session =
1323 crate::hel_controller::test_support::checkpoint_test_session("session-size");
1324 session.container_cpus = Some("2".into());
1325 session.container_memory = Some("3g".into());
1326 session.resource_allocation = Some(SessionResourceAllocation::Container {
1327 cpus: 16,
1328 memory_bytes: 64_000_000_000,
1329 });
1330 let hel_targets::TargetTemplate::LocalPodman(container) = backend_target(
1331 &template,
1332 session.resource_allocation.as_ref(),
1333 ContainerOverrides::for_session(&session),
1334 )
1335 .unwrap() else {
1336 unreachable!()
1337 };
1338 assert!(container.extra_run_args.contains(&"--cpus=2".into()));
1339 assert!(container.extra_run_args.contains(&"--memory=3g".into()));
1340 assert!(!container.extra_run_args.iter().any(|argument| {
1341 argument.starts_with("--cpus=4")
1342 || argument.starts_with("--cpus=16")
1343 || argument.starts_with("--memory=8g")
1344 }));
1345 }
1346 #[test]
1347 fn github_token_is_inherited_only_by_managed_containers() {
1348 let mut podman = hel_targets::TargetTemplate::LocalPodman(ContainerTemplate {
1349 image: "dev:1".into(),
1350 pull_policy: Default::default(),
1351 extra_run_args: vec![],
1352 workspace_storage: Default::default(),
1353 });
1354 assert!(configure_github_token_environment(&mut podman));
1355 let hel_targets::TargetTemplate::LocalPodman(container) = podman else {
1356 unreachable!()
1357 };
1358 assert!(
1359 container
1360 .extra_run_args
1361 .windows(2)
1362 .any(|arguments| arguments == ["--env", "GH_TOKEN"])
1363 );
1364 assert!(
1365 !container
1366 .extra_run_args
1367 .iter()
1368 .any(|argument| argument.contains("github-token"))
1369 );
1370
1371 let mut bare = hel_targets::TargetTemplate::LocalBare;
1372 assert!(!configure_github_token_environment(&mut bare));
1373 assert_eq!(bare, hel_targets::TargetTemplate::LocalBare);
1374 assert_eq!(usable_github_token(" token-value\n"), Some("token-value"));
1375 assert_eq!(usable_github_token("not a token"), None);
1376
1377 let mut bundle = hel_targets::ProjectBundleSpec {
1378 primary: "app".into(),
1379 repositories: vec![hel_targets::RepositorySpec {
1380 url: Some("git@github.com:example/app.git".into()),
1381 push_urls: vec![
1382 "git@github.com:fork/app.git".into(),
1383 "ssh://git@example.test/app.git".into(),
1384 ],
1385 destination: "app".into(),
1386 git_ref: None,
1387 reference: None,
1388 }],
1389 };
1390 use_github_https_urls(&mut bundle);
1391 assert_eq!(
1392 bundle.repositories[0].url.as_deref(),
1393 Some("https://github.com/example/app.git")
1394 );
1395 assert_eq!(
1396 bundle.repositories[0].push_urls,
1397 [
1398 "https://github.com/fork/app.git",
1399 "ssh://git@example.test/app.git"
1400 ]
1401 );
1402 }
1403 fn container_target(
1404 image: &str,
1405 pull_policy: hel::hel_config::ImagePullPolicy,
1406 ) -> ConfigContainer {
1407 ConfigContainer {
1408 image: image.into(),
1409 pull_policy,
1410 platform: None,
1411 cpus: None,
1412 memory: None,
1413 environment: BTreeMap::new(),
1414 workspace_storage: Default::default(),
1415 }
1416 }
1417
1418 #[test]
1419 fn the_image_refresh_plan_covers_every_image_a_launch_no_longer_pulls() {
1420 use hel::hel_config::ImagePullPolicy;
1421
1422 let mut config = HelConfig::default();
1423 config.targets.insert(
1424 "podman".into(),
1425 TargetTemplate::LocalPodman {
1426 container: container_target("ghcr.io/example/dev:latest", ImagePullPolicy::Auto),
1427 },
1428 );
1429 config.targets.insert(
1431 "podman-again".into(),
1432 TargetTemplate::LocalPodman {
1433 container: container_target("ghcr.io/example/dev:latest", ImagePullPolicy::Auto),
1434 },
1435 );
1436 config.targets.insert(
1437 "ssh".into(),
1438 TargetTemplate::SshPodman {
1439 ssh: SshConnection {
1440 host: "builder.example.test".into(),
1441 user: Some("dev".into()),
1442 identity_file: Some(PathBuf::from("/home/dev/.ssh/builder")),
1443 extra_args: Vec::new(),
1444 },
1445 container: ConfigContainer {
1446 platform: Some("linux/amd64".into()),
1447 ..container_target("ghcr.io/example/dev:latest", ImagePullPolicy::Auto)
1448 },
1449 },
1450 );
1451 config.targets.insert(
1452 "docker".into(),
1453 TargetTemplate::LocalDocker {
1454 container: container_target("ghcr.io/example/dev:1.2.3", ImagePullPolicy::Newer),
1455 },
1456 );
1457 config.targets.insert(
1458 "pinned".into(),
1459 TargetTemplate::LocalPodman {
1460 container: container_target(
1461 "ghcr.io/example/dev@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
1462 ImagePullPolicy::Auto,
1463 ),
1464 },
1465 );
1466 config.targets.insert(
1468 "apple".into(),
1469 TargetTemplate::AppleContainer {
1470 container: container_target("ghcr.io/example/dev:latest", ImagePullPolicy::Auto),
1471 },
1472 );
1473
1474 let plan = image_refresh_plan(&config);
1475 assert_eq!(
1476 plan.len(),
1477 3,
1478 "expected one refresh per host and image: {plan:?}"
1479 );
1480
1481 let local = plan
1482 .iter()
1483 .find(|refresh| refresh.host == ImageHost::LocalPodman)
1484 .expect("the auto latest target is refreshed");
1485 assert_eq!(local.pull.program, "podman");
1486 assert_eq!(local.pull.args, ["pull", "ghcr.io/example/dev:latest"]);
1487 assert_eq!(local.prune.args, ["image", "prune", "-f"]);
1488 assert_eq!(
1489 local.image_id.args,
1490 [
1491 "image",
1492 "inspect",
1493 "--format",
1494 "{{.Id}}",
1495 "ghcr.io/example/dev:latest"
1496 ]
1497 );
1498
1499 let docker = plan
1500 .iter()
1501 .find(|refresh| refresh.host == ImageHost::LocalDocker)
1502 .expect("the explicit newer policy is refreshed too");
1503 assert_eq!(docker.pull.program, "docker");
1504 assert_eq!(docker.pull.args, ["pull", "ghcr.io/example/dev:1.2.3"]);
1505 assert_eq!(docker.prune.args, ["image", "prune", "-f"]);
1506
1507 let ssh = plan
1508 .iter()
1509 .find(|refresh| matches!(refresh.host, ImageHost::SshPodman(_)))
1510 .expect("the SSH host is refreshed over its own connection");
1511 assert_eq!(ssh.pull.program, "ssh");
1512 assert!(ssh.pull.args.contains(&"/home/dev/.ssh/builder".to_owned()));
1515 assert!(
1516 ssh.pull
1517 .args
1518 .contains(&"dev@builder.example.test".to_owned())
1519 );
1520 assert_eq!(
1521 ssh.pull.args.last().map(String::as_str),
1522 Some("'podman' 'pull' '--platform=linux/amd64' 'ghcr.io/example/dev:latest'")
1523 );
1524 assert_eq!(
1525 ssh.prune.args.last().map(String::as_str),
1526 Some("'podman' 'image' 'prune' '-f'")
1527 );
1528
1529 assert!(
1530 !plan.iter().any(|refresh| refresh.image.contains("sha256:")),
1531 "a digest-pinned image was refreshed: {plan:?}"
1532 );
1533 }
1534
1535 #[test]
1536 fn ssh_docker_image_refresh_runs_docker_on_the_configured_host() {
1537 use hel::hel_config::ImagePullPolicy;
1538
1539 let mut config = HelConfig::default();
1540 config.targets.insert(
1541 "docker".into(),
1542 TargetTemplate::SshDocker {
1543 ssh: SshConnection {
1544 host: "builder.example.test".into(),
1545 user: Some("dev".into()),
1546 identity_file: None,
1547 extra_args: Vec::new(),
1548 },
1549 container: ConfigContainer {
1550 image: "ghcr.io/example/dev:latest".into(),
1551 pull_policy: ImagePullPolicy::Auto,
1552 platform: Some("linux/amd64".into()),
1553 cpus: None,
1554 memory: None,
1555 environment: BTreeMap::new(),
1556 workspace_storage: Default::default(),
1557 },
1558 },
1559 );
1560
1561 let refresh = image_refresh_plan(&config).pop().expect("refresh plan");
1562 assert_eq!(
1563 refresh.host,
1564 ImageHost::SshDocker(backend_ssh(match config.targets.get("docker").unwrap() {
1565 TargetTemplate::SshDocker { ssh, .. } => ssh,
1566 _ => unreachable!(),
1567 }))
1568 );
1569 assert_eq!(refresh.pull.program, "ssh");
1570 assert_eq!(
1571 refresh.pull.args.last().map(String::as_str),
1572 Some("'docker' 'pull' '--platform=linux/amd64' 'ghcr.io/example/dev:latest'")
1573 );
1574 assert_eq!(
1575 refresh.prune.args.last().map(String::as_str),
1576 Some("'docker' 'image' 'prune' '-f'")
1577 );
1578 }
1579
1580 #[test]
1581 fn aws_resource_options_follow_the_launch_template_family() {
1582 let mut config = HelConfig::default();
1583 config.targets.insert(
1584 "aws".into(),
1585 TargetTemplate::AwsEc2 {
1586 aws_profile: None,
1587 region: "us-east-1".into(),
1588 launch_template: "hel-runson".into(),
1589 launch_template_version: None,
1590 ssh_user: "ubuntu".into(),
1591 address_source: AwsAddressSource::PublicIp,
1592 identity_file: None,
1593 ssh_args: Vec::new(),
1594 },
1595 );
1596 let executor = PreflightExecutor {
1597 outputs: RefCell::new(vec![
1598 CommandOutput {
1599 status: 0,
1600 stdout: br#"{"LaunchTemplateVersions":[{"LaunchTemplateData":{"InstanceType":"m8i-flex.large"}}]}"#.to_vec(),
1601 stderr: Vec::new(),
1602 },
1603 CommandOutput {
1604 status: 0,
1605 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(),
1606 stderr: Vec::new(),
1607 },
1608 ]),
1609 notices: RefCell::new(vec![]),
1610 };
1611 let controller = Controller {
1612 config,
1613 state: HelState::default(),
1614 };
1615
1616 let options = controller
1617 .resolve_aws_resource_options("aws", &executor)
1618 .unwrap();
1619 assert_eq!(
1620 options.iter().map(allocation_cpus).collect::<Vec<_>>(),
1621 [8, 16]
1622 );
1623 }
1624 #[test]
1625 fn deployment_capacity_groups_local_and_same_host_targets() {
1626 let container = || ConfigContainer {
1627 image: "dev:1".into(),
1628 pull_policy: Default::default(),
1629 platform: None,
1630 cpus: None,
1631 memory: None,
1632 environment: BTreeMap::new(),
1633 workspace_storage: Default::default(),
1634 };
1635 let ssh = |host: &str| SshConnection {
1636 host: host.into(),
1637 user: Some("builder".into()),
1638 identity_file: None,
1639 extra_args: Vec::new(),
1640 };
1641 let config = HelConfig {
1642 version: hel::hel_config::CONFIG_VERSION,
1643 sessions_side: Default::default(),
1644 advanced: Default::default(),
1645 show_stopped_sessions: true,
1646 newer_config_version: None,
1647 spinner: Default::default(),
1648 theme: Default::default(),
1649 phone: Default::default(),
1650 review: Default::default(),
1651 startup: Default::default(),
1652 profiles: BTreeMap::new(),
1653 bundles: BTreeMap::new(),
1654 targets: BTreeMap::from([
1655 (
1656 "apple".into(),
1657 TargetTemplate::AppleContainer {
1658 container: container(),
1659 },
1660 ),
1661 (
1662 "local".into(),
1663 TargetTemplate::LocalPodman {
1664 container: container(),
1665 },
1666 ),
1667 (
1668 "bare".into(),
1669 TargetTemplate::SshBare {
1670 ssh: ssh("builder"),
1671 permissions: hel::hel_config::PermissionMode::Yolo,
1672 workspace_prefix: ".local/share/hel/workspaces".into(),
1673 },
1674 ),
1675 (
1676 "remote-container".into(),
1677 TargetTemplate::SshPodman {
1678 ssh: ssh("builder"),
1679 container: container(),
1680 },
1681 ),
1682 (
1683 "alias".into(),
1684 TargetTemplate::SshBare {
1685 ssh: ssh("builder-alias"),
1686 permissions: hel::hel_config::PermissionMode::Yolo,
1687 workspace_prefix: ".local/share/hel/workspaces".into(),
1688 },
1689 ),
1690 ]),
1691 };
1692 let controller = Controller {
1693 config,
1694 state: HelState::default(),
1695 };
1696
1697 let targets = controller.deployment_capacity_targets();
1698
1699 assert_eq!(targets.len(), 3);
1700 let local = targets.iter().find(|target| target.id == "local").unwrap();
1701 assert_eq!(local.target_ids, ["apple", "local"]);
1702 let builder = targets
1703 .iter()
1704 .find(|target| target.id == "ssh:builder")
1705 .unwrap();
1706 assert_eq!(builder.target_ids, ["bare", "remote-container"]);
1707 assert_eq!(builder.probes.len(), 1);
1708 assert!(
1709 targets
1710 .iter()
1711 .any(|target| target.id == "ssh:builder-alias")
1712 );
1713 }
1714 struct PreflightExecutor {
1715 outputs: RefCell<Vec<CommandOutput>>,
1716 notices: RefCell<Vec<String>>,
1717 }
1718 impl CommandExecutor for PreflightExecutor {
1719 fn execute(&self, _command: &CommandSpec) -> Result<CommandOutput> {
1720 Ok(self.outputs.borrow_mut().remove(0))
1721 }
1722
1723 fn notify_notice(&self, notice: &str) {
1724 self.notices.borrow_mut().push(notice.to_owned());
1725 }
1726 }
1727 #[test]
1728 fn local_podman_preflight_failures_recommend_doctor() {
1729 let template = TargetTemplate::LocalPodman {
1730 container: ConfigContainer {
1731 image: "ubuntu:24.04".into(),
1732 pull_policy: Default::default(),
1733 platform: None,
1734 cpus: None,
1735 memory: None,
1736 environment: std::collections::BTreeMap::new(),
1737 workspace_storage: Default::default(),
1738 },
1739 };
1740 let executor = PreflightExecutor {
1741 outputs: RefCell::new(vec![CommandOutput {
1742 status: 0,
1743 stdout: b"podman version 3.4.7\n".to_vec(),
1744 stderr: vec![],
1745 }]),
1746 notices: RefCell::new(vec![]),
1747 };
1748
1749 let error = preflight_target(&template, &executor)
1750 .unwrap_err()
1751 .to_string();
1752 assert!(error.contains("mj doctor"));
1753 assert!(error.contains("Podman 4.0.0"));
1754 }
1755 #[test]
1756 fn ssh_podman_preflight_failures_name_the_destination_and_recommend_doctor() {
1757 let template = TargetTemplate::SshPodman {
1758 ssh: SshConnection {
1759 host: "example.test".into(),
1760 user: Some("dev".into()),
1761 identity_file: None,
1762 extra_args: vec![],
1763 },
1764 container: ConfigContainer {
1765 image: "ubuntu:24.04".into(),
1766 pull_policy: Default::default(),
1767 platform: None,
1768 cpus: None,
1769 memory: None,
1770 environment: std::collections::BTreeMap::new(),
1771 workspace_storage: Default::default(),
1772 },
1773 };
1774 let executor = PreflightExecutor {
1775 outputs: RefCell::new(vec![CommandOutput {
1776 status: 0,
1777 stdout: b"podman version 3.4.7\n".to_vec(),
1778 stderr: vec![],
1779 }]),
1780 notices: RefCell::new(vec![]),
1781 };
1782
1783 let error = preflight_target(&template, &executor)
1784 .unwrap_err()
1785 .to_string();
1786 assert!(error.contains("mj doctor"));
1787 assert!(error.contains("dev@example.test"));
1788 assert!(error.contains("Podman 4.0.0"));
1789 }
1790 #[test]
1791 fn ssh_podman_preflight_notifies_when_remote_user_lingering_is_disabled() {
1792 let template = TargetTemplate::SshPodman {
1793 ssh: SshConnection {
1794 host: "example.test".into(),
1795 user: Some("dev".into()),
1796 identity_file: None,
1797 extra_args: vec![],
1798 },
1799 container: ConfigContainer {
1800 image: "ubuntu:24.04".into(),
1801 pull_policy: Default::default(),
1802 platform: None,
1803 cpus: None,
1804 memory: None,
1805 environment: std::collections::BTreeMap::new(),
1806 workspace_storage: Default::default(),
1807 },
1808 };
1809 let executor = PreflightExecutor {
1810 outputs: RefCell::new(vec![
1811 CommandOutput {
1812 status: 0,
1813 stdout: b"podman version 5.4.2\n".to_vec(),
1814 stderr: vec![],
1815 },
1816 CommandOutput {
1817 status: 0,
1818 stdout: b"true\n".to_vec(),
1819 stderr: vec![],
1820 },
1821 CommandOutput {
1822 status: 0,
1823 stdout: b"0 1000 1\n1 100000 65536\n".to_vec(),
1824 stderr: vec![],
1825 },
1826 CommandOutput {
1827 status: 0,
1828 stdout: b"no\n".to_vec(),
1829 stderr: vec![],
1830 },
1831 ]),
1832 notices: RefCell::new(vec![]),
1833 };
1834
1835 preflight_target(&template, &executor).unwrap();
1836
1837 let notices = executor.notices.borrow();
1838 assert_eq!(notices.len(), 1);
1839 assert!(notices[0].contains("last SSH connection closes"));
1840 assert!(notices[0].contains("sudo loginctl enable-linger"));
1841 }
1842 #[test]
1843 fn apple_container_preflight_failures_recommend_doctor() {
1844 let template = TargetTemplate::AppleContainer {
1845 container: ConfigContainer {
1846 image: "ubuntu:24.04".into(),
1847 pull_policy: Default::default(),
1848 platform: None,
1849 cpus: None,
1850 memory: None,
1851 environment: std::collections::BTreeMap::new(),
1852 workspace_storage: Default::default(),
1853 },
1854 };
1855 let executor = PreflightExecutor {
1856 outputs: RefCell::new(vec![CommandOutput {
1857 status: 1,
1858 stdout: vec![],
1859 stderr: b"daemon is not running".to_vec(),
1860 }]),
1861 notices: RefCell::new(vec![]),
1862 };
1863
1864 let error = preflight_target(&template, &executor)
1865 .unwrap_err()
1866 .to_string();
1867 assert!(error.contains("mj doctor"));
1868 assert!(error.contains("daemon is not running"));
1869 }
1870}