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