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