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