1use crate::{
8 instance_catalog::{self, Architecture, WorkloadRequirements},
9 CapacityGroup, CapacityGroupScalePolicy, ComputeChoiceRange, ComputePoolSelection, Container,
10 Daemon, ErrorData, FailureDomainSelection, GpuSpec, MachineProfile, Platform, ResourceSpec,
11 Stack,
12};
13use alien_error::{AlienError, Result};
14use serde::{Deserialize, Serialize};
15use std::collections::HashMap;
16
17#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
19#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
20#[serde(rename_all = "camelCase")]
21pub struct ComputePlan {
22 pub pools: Vec<ComputePoolPlan>,
24}
25
26#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
28#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
29#[serde(rename_all = "camelCase")]
30pub struct ComputePoolPlan {
31 pub pool_id: String,
33 pub workloads: Vec<String>,
35 pub requirements: MachineProfile,
37 pub scale: CapacityGroupScalePolicy,
39 pub selected: ComputePoolSelection,
41 pub recommended: ComputePoolSelection,
43 pub machines: Vec<ComputeMachineOption>,
45 #[serde(default, skip_serializing_if = "Vec::is_empty")]
47 pub errors: Vec<String>,
48}
49
50#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
52#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
53#[serde(rename_all = "camelCase")]
54pub struct ComputeMachineOption {
55 pub machine: String,
57 pub profile: MachineProfile,
59 pub recommended: bool,
61}
62
63pub fn plan_compute(
65 stack: &Stack,
66 platform: Platform,
67 selected_settings: Option<&crate::ComputeSettings>,
68) -> Result<ComputePlan, ErrorData> {
69 let mut groups = collect_workload_groups(stack)?;
70 merge_explicit_compute_groups(stack, &mut groups)?;
71
72 let mut pool_ids: Vec<String> = groups.keys().cloned().collect();
73 pool_ids.sort();
74
75 let mut pools = Vec::new();
76 for pool_id in pool_ids {
77 let group = groups.remove(&pool_id).expect("pool id came from map keys");
78 let requirements = group.requirements;
79 let selected = selected_settings.and_then(|settings| settings.pools.get(&pool_id));
80 let recommended = recommended_selection(
81 platform,
82 &requirements,
83 &group.scale,
84 group.requires_failure_domain,
85 )?;
86 let mut selected_choice = selected.cloned().unwrap_or_else(|| recommended.clone());
87 if selected_choice.failure_domains().is_none() {
88 if let Some(default_failure_domains) = recommended.failure_domains().cloned() {
89 match &mut selected_choice {
90 ComputePoolSelection::Fixed {
91 failure_domains, ..
92 }
93 | ComputePoolSelection::Autoscale {
94 failure_domains, ..
95 } => *failure_domains = Some(default_failure_domains),
96 }
97 }
98 }
99 let errors = validate_compute_pool_selection(
100 platform,
101 &pool_id,
102 &selected_choice,
103 &requirements,
104 &group.scale,
105 );
106 let machines = machine_options(platform, &requirements, selected_choice.machine())?;
107
108 pools.push(ComputePoolPlan {
109 pool_id,
110 workloads: group.workloads,
111 requirements: requirements_to_profile(&requirements),
112 scale: group.scale,
113 selected: selected_choice,
114 recommended,
115 machines,
116 errors,
117 });
118 }
119
120 Ok(ComputePlan { pools })
121}
122
123#[derive(Debug, Clone)]
124struct PlannedGroup {
125 workloads: Vec<String>,
126 requirements: WorkloadRequirements,
127 scale: CapacityGroupScalePolicy,
128 requires_failure_domain: bool,
129}
130
131fn collect_workload_groups(stack: &Stack) -> Result<HashMap<String, PlannedGroup>, ErrorData> {
132 let mut groups: HashMap<String, Vec<Workload>> = HashMap::new();
133
134 for entry in stack.resources.values() {
135 if let Some(container) = entry.config.downcast_ref::<Container>() {
136 groups
137 .entry(
138 container
139 .pool
140 .clone()
141 .unwrap_or_else(|| needed_container_pool(container).to_string()),
142 )
143 .or_default()
144 .push(Workload::from_container(container)?);
145 }
146 if let Some(daemon) = entry.config.downcast_ref::<Daemon>() {
147 if daemon.cluster.is_some() {
148 groups
149 .entry(daemon.pool.clone().unwrap_or_else(|| "general".to_string()))
150 .or_default()
151 .push(Workload::from_daemon(daemon)?);
152 }
153 }
154 }
155
156 let mut planned = HashMap::new();
157 for (pool_id, workloads) in groups {
158 let requirements = aggregate_workloads(&workloads);
159 let requires_failure_domain = workloads
160 .iter()
161 .any(|workload| workload.requires_failure_domain);
162 let min_size = default_min_machines(&requirements);
163 let max_size = default_max_machines(&requirements);
164 planned.insert(
165 pool_id,
166 PlannedGroup {
167 workloads: workloads.into_iter().map(|w| w.id).collect(),
168 scale: CapacityGroupScalePolicy::from_selected_bounds(min_size, max_size),
169 requirements,
170 requires_failure_domain,
171 },
172 );
173 }
174 if planned.is_empty() {
175 let requirements = default_requirements();
176 planned.insert(
177 "general".to_string(),
178 PlannedGroup {
179 workloads: Vec::new(),
180 scale: CapacityGroupScalePolicy::from_selected_bounds(1, 1),
181 requirements,
182 requires_failure_domain: false,
183 },
184 );
185 }
186 Ok(planned)
187}
188
189fn merge_explicit_compute_groups(
190 stack: &Stack,
191 groups: &mut HashMap<String, PlannedGroup>,
192) -> Result<(), ErrorData> {
193 for entry in stack.resources.values() {
194 let Some(cluster) = entry.config.downcast_ref::<crate::ComputeCluster>() else {
195 continue;
196 };
197 for group in &cluster.capacity_groups {
198 let explicit_requirements = profile_to_requirements(
199 group.profile.as_ref(),
200 group.nested_virtualization.unwrap_or(false),
201 );
202 let scale = group.scale_policy.clone().unwrap_or_else(|| {
203 CapacityGroupScalePolicy::from_selected_bounds(group.min_size, group.max_size)
204 });
205 groups
206 .entry(group.group_id.clone())
207 .and_modify(|planned| {
208 merge_requirements(&mut planned.requirements, &explicit_requirements);
209 planned.scale = merge_scale_policy(&planned.scale, &scale);
210 })
211 .or_insert_with(|| PlannedGroup {
212 workloads: Vec::new(),
213 scale,
214 requirements: explicit_requirements,
215 requires_failure_domain: false,
216 });
217 }
218 }
219 Ok(())
220}
221
222fn recommended_selection(
223 platform: Platform,
224 requirements: &WorkloadRequirements,
225 scale: &CapacityGroupScalePolicy,
226 requires_failure_domain: bool,
227) -> Result<ComputePoolSelection, ErrorData> {
228 let machine = match platform {
229 Platform::Aws | Platform::Gcp | Platform::Azure => Some(
230 instance_catalog::select_instance_type(platform, requirements)
231 .map_err(|message| {
232 AlienError::new(ErrorData::GenericError {
233 message: format!("Failed to select {platform} machine: {message}"),
234 })
235 })?
236 .instance_type
237 .to_string(),
238 ),
239 Platform::Local | Platform::Kubernetes | Platform::Machines | Platform::Test => None,
240 };
241
242 let failure_domains = (requires_failure_domain
243 && matches!(platform, Platform::Aws | Platform::Gcp | Platform::Azure))
244 .then_some(FailureDomainSelection {
245 spread: 1,
246 selected_failure_domains: Vec::new(),
247 });
248
249 match scale {
250 CapacityGroupScalePolicy::Fixed { machines } => Ok(ComputePoolSelection::Fixed {
251 machines: machines.default.max(1),
252 machine,
253 failure_domains,
254 }),
255 CapacityGroupScalePolicy::Autoscale { min, max } => Ok(ComputePoolSelection::Autoscale {
256 min: min.default,
257 max: max.default.max(min.default),
258 machine,
259 failure_domains,
260 }),
261 }
262}
263
264pub fn validate_compute_pool_selection(
267 platform: Platform,
268 pool_id: &str,
269 selection: &ComputePoolSelection,
270 requirements: &WorkloadRequirements,
271 scale: &CapacityGroupScalePolicy,
272) -> Vec<String> {
273 let mut errors = Vec::new();
274 if let Err(message) = selection.validate() {
275 errors.push(message);
276 }
277 if let Err(message) = validate_selection_against_scale(selection, scale) {
278 errors.push(format!("Pool '{pool_id}' {message}"));
279 }
280 if matches!(platform, Platform::Aws | Platform::Gcp | Platform::Azure) {
281 match selection.machine() {
282 Some(machine) => match instance_catalog::find_instance_type(platform, machine) {
283 Some(spec) => {
284 let architecture = requirements.architecture.unwrap_or(spec.architecture);
285 if !instance_satisfies(spec, requirements, architecture) {
286 errors.push(format!(
287 "{} machine '{}' does not satisfy pool '{}' requirements",
288 platform, machine, pool_id
289 ));
290 }
291 }
292 None => errors.push(format!(
293 "Unknown {} machine '{}' for pool '{}'",
294 platform, machine, pool_id
295 )),
296 },
297 None => errors.push(format!(
298 "Pool '{}' requires a provider machine on {}",
299 pool_id, platform
300 )),
301 }
302 }
303 errors
304}
305
306pub fn capacity_group_requirements(group: &CapacityGroup) -> WorkloadRequirements {
308 profile_to_requirements(
309 group.profile.as_ref(),
310 group.nested_virtualization.unwrap_or(false),
311 )
312}
313
314fn machine_options(
315 platform: Platform,
316 requirements: &WorkloadRequirements,
317 selected_machine: Option<&str>,
318) -> Result<Vec<ComputeMachineOption>, ErrorData> {
319 if !matches!(platform, Platform::Aws | Platform::Gcp | Platform::Azure) {
320 return Ok(Vec::new());
321 }
322 let recommended =
323 instance_catalog::select_instance_type(platform, requirements).map_err(|message| {
324 AlienError::new(ErrorData::GenericError {
325 message: format!("Failed to select {platform} machine: {message}"),
326 })
327 })?;
328 let resolved_architecture = requirements
329 .architecture
330 .or_else(|| {
331 selected_machine.and_then(|machine| {
332 instance_catalog::find_instance_type(platform, machine)
333 .map(|spec| spec.architecture)
334 })
335 })
336 .or(recommended.profile.architecture)
337 .ok_or_else(|| {
338 AlienError::new(ErrorData::GenericError {
339 message: format!("Selected {platform} machine has no CPU architecture"),
340 })
341 })?;
342 let recommended = recommended.instance_type.to_string();
343
344 let mut options: Vec<ComputeMachineOption> = instance_catalog::catalog_for_platform(platform)
345 .into_iter()
346 .filter(|spec| instance_satisfies(spec, requirements, resolved_architecture))
347 .map(|spec| ComputeMachineOption {
348 machine: spec.name.to_string(),
349 profile: spec.to_machine_profile(),
350 recommended: spec.name == recommended || Some(spec.name) == selected_machine,
351 })
352 .collect();
353 options.sort_by(|a, b| a.machine.cmp(&b.machine));
354 Ok(options)
355}
356
357fn instance_satisfies(
358 spec: &instance_catalog::InstanceTypeSpec,
359 requirements: &WorkloadRequirements,
360 resolved_architecture: Architecture,
361) -> bool {
362 if spec.architecture != resolved_architecture {
363 return false;
364 }
365 if requirements.nested_virt && !spec.is_nested_virt_capable() {
366 return false;
367 }
368 if spec.vcpu < requirements.max_cpu_per_container.ceil() as u32 {
369 return false;
370 }
371 if spec.memory_bytes < requirements.max_memory_per_container {
372 return false;
373 }
374 if spec.ephemeral_storage_bytes < requirements.max_ephemeral_storage_bytes {
375 return false;
376 }
377 match (&requirements.gpu, spec.gpu) {
378 (Some(required), Some(actual)) => {
379 (required.gpu_type == "any" || required.gpu_type == actual.gpu_type)
380 && actual.count >= required.count
381 }
382 (Some(_), None) => false,
383 (None, _) => true,
384 }
385}
386
387#[derive(Debug, Clone)]
388struct Workload {
389 id: String,
390 cpu: f64,
391 memory_bytes: u64,
392 desired_replicas: f64,
393 max_replicas: f64,
394 ephemeral_storage_bytes: u64,
395 gpu: Option<GpuSpec>,
396 requires_failure_domain: bool,
397}
398
399impl Workload {
400 fn from_container(container: &Container) -> Result<Self, ErrorData> {
401 let cpu = parse_cpu(&container.id, &container.cpu)?;
402 let memory_bytes = parse_memory(&container.id, &container.memory)?;
403 let desired_replicas = container
404 .autoscaling
405 .as_ref()
406 .map(|a| a.desired)
407 .or(container.replicas)
408 .unwrap_or(1) as f64;
409 let max_replicas = container
410 .autoscaling
411 .as_ref()
412 .map(|a| a.max)
413 .or(container.replicas)
414 .unwrap_or(1) as f64;
415 let ephemeral_storage_bytes = container
416 .ephemeral_storage
417 .as_deref()
418 .map(instance_catalog::parse_memory_bytes)
419 .transpose()
420 .map_err(|message| {
421 AlienError::new(ErrorData::GenericError {
422 message: format!(
423 "Failed to parse ephemeral storage for '{}': {message}",
424 container.id
425 ),
426 })
427 })?
428 .unwrap_or(0);
429
430 Ok(Self {
431 id: container.id.clone(),
432 cpu,
433 memory_bytes,
434 desired_replicas,
435 max_replicas,
436 ephemeral_storage_bytes,
437 gpu: container.gpu.as_ref().map(|gpu| GpuSpec {
438 gpu_type: gpu.gpu_type.clone(),
439 count: gpu.count,
440 }),
441 requires_failure_domain: container.stateful && container.persistent_storage.is_some(),
442 })
443 }
444
445 fn from_daemon(daemon: &Daemon) -> Result<Self, ErrorData> {
446 Ok(Self {
447 id: daemon.id.clone(),
448 cpu: parse_cpu(&daemon.id, &daemon.cpu)?,
449 memory_bytes: parse_memory(&daemon.id, &daemon.memory)?,
450 desired_replicas: 1.0,
451 max_replicas: 1.0,
452 ephemeral_storage_bytes: 0,
453 gpu: None,
454 requires_failure_domain: false,
455 })
456 }
457}
458
459fn parse_cpu(resource_id: &str, spec: &ResourceSpec) -> Result<f64, ErrorData> {
460 instance_catalog::parse_cpu(&spec.desired).map_err(|message| {
461 AlienError::new(ErrorData::GenericError {
462 message: format!(
463 "Failed to parse CPU requirement '{}' for '{}': {message}",
464 spec.desired, resource_id
465 ),
466 })
467 })
468}
469
470fn parse_memory(resource_id: &str, spec: &ResourceSpec) -> Result<u64, ErrorData> {
471 instance_catalog::parse_memory_bytes(&spec.desired).map_err(|message| {
472 AlienError::new(ErrorData::GenericError {
473 message: format!(
474 "Failed to parse memory requirement '{}' for '{}': {message}",
475 spec.desired, resource_id
476 ),
477 })
478 })
479}
480
481fn aggregate_workloads(workloads: &[Workload]) -> WorkloadRequirements {
482 let mut requirements = default_requirements();
483 requirements.total_cpu_at_desired = 0.0;
484 requirements.total_memory_bytes_at_desired = 0;
485 requirements.total_cpu_at_max = 0.0;
486 requirements.total_memory_bytes_at_max = 0;
487 requirements.max_cpu_per_container = 0.0;
488 requirements.max_memory_per_container = 0;
489 requirements.max_ephemeral_storage_bytes = 0;
490 requirements.gpu = None;
491
492 for workload in workloads {
493 requirements.total_cpu_at_desired += workload.cpu * workload.desired_replicas;
494 requirements.total_cpu_at_max += workload.cpu * workload.max_replicas;
495 requirements.total_memory_bytes_at_desired +=
496 (workload.memory_bytes as f64 * workload.desired_replicas) as u64;
497 requirements.total_memory_bytes_at_max +=
498 (workload.memory_bytes as f64 * workload.max_replicas) as u64;
499 requirements.max_cpu_per_container = requirements.max_cpu_per_container.max(workload.cpu);
500 requirements.max_memory_per_container = requirements
501 .max_memory_per_container
502 .max(workload.memory_bytes);
503 requirements.max_ephemeral_storage_bytes = requirements
504 .max_ephemeral_storage_bytes
505 .max(workload.ephemeral_storage_bytes);
506 if requirements.gpu.is_none() {
507 requirements.gpu = workload.gpu.clone();
508 }
509 }
510 requirements
511}
512
513fn default_requirements() -> WorkloadRequirements {
514 WorkloadRequirements {
515 total_cpu_at_desired: 1.0,
516 total_memory_bytes_at_desired: 2 * 1024 * 1024 * 1024,
517 total_cpu_at_max: 1.0,
518 total_memory_bytes_at_max: 2 * 1024 * 1024 * 1024,
519 max_cpu_per_container: 1.0,
520 max_memory_per_container: 2 * 1024 * 1024 * 1024,
521 max_ephemeral_storage_bytes: 0,
522 gpu: None,
523 architecture: None,
524 nested_virt: false,
525 }
526}
527
528fn profile_to_requirements(
529 profile: Option<&MachineProfile>,
530 nested_virt: bool,
531) -> WorkloadRequirements {
532 let Some(profile) = profile else {
533 return WorkloadRequirements {
534 nested_virt,
535 ..default_requirements()
536 };
537 };
538 let cpu = instance_catalog::parse_cpu(&profile.cpu).unwrap_or(1.0);
539 WorkloadRequirements {
540 total_cpu_at_desired: cpu,
541 total_memory_bytes_at_desired: profile.memory_bytes,
542 total_cpu_at_max: cpu,
543 total_memory_bytes_at_max: profile.memory_bytes,
544 max_cpu_per_container: cpu,
545 max_memory_per_container: profile.memory_bytes,
546 max_ephemeral_storage_bytes: profile.ephemeral_storage_bytes,
547 gpu: profile.gpu.clone(),
548 architecture: profile.architecture,
549 nested_virt,
550 }
551}
552
553fn merge_requirements(existing: &mut WorkloadRequirements, declared: &WorkloadRequirements) {
554 existing.total_cpu_at_desired = existing
555 .total_cpu_at_desired
556 .max(declared.total_cpu_at_desired);
557 existing.total_memory_bytes_at_desired = existing
558 .total_memory_bytes_at_desired
559 .max(declared.total_memory_bytes_at_desired);
560 existing.total_cpu_at_max = existing.total_cpu_at_max.max(declared.total_cpu_at_max);
561 existing.total_memory_bytes_at_max = existing
562 .total_memory_bytes_at_max
563 .max(declared.total_memory_bytes_at_max);
564 existing.max_cpu_per_container = existing
565 .max_cpu_per_container
566 .max(declared.max_cpu_per_container);
567 existing.max_memory_per_container = existing
568 .max_memory_per_container
569 .max(declared.max_memory_per_container);
570 existing.max_ephemeral_storage_bytes = existing
571 .max_ephemeral_storage_bytes
572 .max(declared.max_ephemeral_storage_bytes);
573 if existing.gpu.is_none() {
574 existing.gpu = declared.gpu.clone();
575 }
576 existing.nested_virt |= declared.nested_virt;
577 if existing.architecture.is_none() {
578 existing.architecture = declared.architecture;
579 }
580}
581
582fn requirements_to_profile(requirements: &WorkloadRequirements) -> MachineProfile {
583 MachineProfile {
584 cpu: requirements.max_cpu_per_container.to_string(),
585 memory_bytes: requirements.max_memory_per_container,
586 ephemeral_storage_bytes: requirements.max_ephemeral_storage_bytes,
587 architecture: requirements.architecture,
588 gpu: requirements.gpu.clone(),
589 }
590}
591
592fn merge_scale_policy(
593 existing: &CapacityGroupScalePolicy,
594 declared: &CapacityGroupScalePolicy,
595) -> CapacityGroupScalePolicy {
596 match (existing, declared) {
597 (
598 CapacityGroupScalePolicy::Fixed {
599 machines: existing_machines,
600 },
601 CapacityGroupScalePolicy::Fixed {
602 machines: declared_machines,
603 },
604 ) => CapacityGroupScalePolicy::Fixed {
605 machines: merge_choice_range(existing_machines, declared_machines),
606 },
607 (_, declared) => declared.clone(),
608 }
609}
610
611fn merge_choice_range(
612 existing: &ComputeChoiceRange,
613 declared: &ComputeChoiceRange,
614) -> ComputeChoiceRange {
615 ComputeChoiceRange {
616 min: existing.min.max(declared.min),
617 max: existing.max.max(declared.max),
618 default: declared.default,
619 }
620}
621
622fn validate_selection_against_scale(
623 selection: &ComputePoolSelection,
624 scale: &CapacityGroupScalePolicy,
625) -> std::result::Result<(), String> {
626 match (selection, scale) {
627 (
628 ComputePoolSelection::Fixed { machines, .. },
629 CapacityGroupScalePolicy::Fixed { machines: allowed },
630 ) => {
631 if allowed.contains(*machines) {
632 Ok(())
633 } else {
634 Err(format!(
635 "fixed machine count {machines} is outside the allowed range {}-{}",
636 allowed.min, allowed.max
637 ))
638 }
639 }
640 (
641 ComputePoolSelection::Autoscale { min, max, .. },
642 CapacityGroupScalePolicy::Autoscale {
643 min: allowed_min,
644 max: allowed_max,
645 },
646 ) => {
647 if !allowed_min.contains(*min) {
648 return Err(format!(
649 "autoscale minimum {min} is outside the allowed range {}-{}",
650 allowed_min.min, allowed_min.max
651 ));
652 }
653 if !allowed_max.contains(*max) {
654 return Err(format!(
655 "autoscale maximum {max} is outside the allowed range {}-{}",
656 allowed_max.min, allowed_max.max
657 ));
658 }
659 Ok(())
660 }
661 (ComputePoolSelection::Fixed { .. }, CapacityGroupScalePolicy::Autoscale { .. }) => {
662 Err("must use autoscale mode".to_string())
663 }
664 (ComputePoolSelection::Autoscale { .. }, CapacityGroupScalePolicy::Fixed { .. }) => {
665 Err("must use fixed mode".to_string())
666 }
667 }
668}
669
670fn needed_container_pool(container: &Container) -> &'static str {
671 if container.stateful && container.persistent_storage.is_some() {
672 return "stateful";
673 }
674 if container.gpu.is_some() {
675 return "gpu";
676 }
677 if let Some(storage) = &container.ephemeral_storage {
678 if instance_catalog::parse_memory_bytes(storage).unwrap_or(0) > 200 * 1024 * 1024 * 1024 {
679 return "storage";
680 }
681 }
682 "general"
683}
684
685fn default_min_machines(requirements: &WorkloadRequirements) -> u32 {
686 if requirements.total_cpu_at_desired > 0.0 || requirements.total_memory_bytes_at_desired > 0 {
687 1
688 } else {
689 0
690 }
691}
692
693fn default_max_machines(requirements: &WorkloadRequirements) -> u32 {
694 let min = default_min_machines(requirements);
695 let by_cpu =
696 (requirements.total_cpu_at_max / requirements.max_cpu_per_container.max(1.0)).ceil() as u32;
697 let by_mem = requirements
698 .total_memory_bytes_at_max
699 .div_ceil(requirements.max_memory_per_container.max(1)) as u32;
700 min.max(by_cpu).max(by_mem).max(1)
701}
702
703#[cfg(test)]
704mod tests {
705 use super::*;
706 use crate::{
707 instance_catalog::Architecture, CapacityGroup, CapacityGroupScalePolicy,
708 ComputeChoiceRange, ComputeCluster, ComputeSettings, ContainerCode, DaemonCode, Resource,
709 ResourceEntry, ResourceLifecycle, Stack,
710 };
711
712 fn stack_with_container() -> Stack {
713 let container = Container::new("api".to_string())
714 .code(ContainerCode::Image {
715 image: "api:latest".to_string(),
716 })
717 .cpu(ResourceSpec {
718 min: "1".to_string(),
719 desired: "2".to_string(),
720 })
721 .memory(ResourceSpec {
722 min: "2Gi".to_string(),
723 desired: "4Gi".to_string(),
724 })
725 .permissions("api".to_string())
726 .build();
727 Stack {
728 id: "test".to_string(),
729 resources: [(
730 "api".to_string(),
731 ResourceEntry {
732 config: Resource::new(container),
733 lifecycle: ResourceLifecycle::Live,
734 dependencies: Vec::new(),
735 remote_access: false,
736 },
737 )]
738 .into_iter()
739 .collect(),
740 permissions: crate::permissions::PermissionsConfig::default(),
741 supported_platforms: None,
742 inputs: vec![],
743 }
744 }
745
746 #[test]
747 fn cloud_plan_recommends_provider_machine_without_mutating_selection() {
748 let stack = stack_with_container();
749
750 let plan = plan_compute(&stack, Platform::Aws, None).expect("plan should build");
751
752 let pool = plan.pools.first().expect("general pool should exist");
753 assert_eq!(pool.pool_id, "general");
754 assert_eq!(pool.workloads, vec!["api"]);
755 assert!(pool.selected.machine().is_some());
756 assert!(pool.machines.iter().any(|machine| machine.recommended));
757 }
758
759 #[test]
760 fn cloud_plan_only_offers_machines_matching_the_default_image_target() {
761 let stack = stack_with_container();
762
763 for platform in [Platform::Aws, Platform::Gcp, Platform::Azure] {
764 let plan = plan_compute(&stack, platform, None).expect("plan should build");
765 let pool = plan.pools.first().expect("general pool should exist");
766 let expected = instance_catalog::default_architecture(platform)
767 .expect("managed cloud should have a default architecture");
768
769 assert!(pool.machines.iter().all(|machine| {
770 instance_catalog::find_instance_type(platform, &machine.machine)
771 .is_some_and(|spec| spec.architecture == expected)
772 }));
773 }
774 }
775
776 #[test]
777 fn selected_machine_is_preserved_as_static_deployment_choice() {
778 let stack = stack_with_container();
779 let settings = ComputeSettings {
780 pools: [(
781 "general".to_string(),
782 ComputePoolSelection::Fixed {
783 machines: 1,
784 machine: Some("m7g.xlarge".to_string()),
785 failure_domains: None,
786 },
787 )]
788 .into_iter()
789 .collect(),
790 };
791
792 let plan = plan_compute(&stack, Platform::Aws, Some(&settings)).expect("plan should build");
793
794 let pool = plan.pools.first().expect("general pool should exist");
795 assert_eq!(pool.selected.machine(), Some("m7g.xlarge"));
796 assert!(pool.errors.is_empty());
797 }
798
799 #[test]
800 fn selected_machine_defines_architecture_when_workloads_do_not() {
801 let stack = stack_with_container();
802 let settings = ComputeSettings {
803 pools: [(
804 "general".to_string(),
805 ComputePoolSelection::Fixed {
806 machines: 1,
807 machine: Some("m7i.xlarge".to_string()),
808 failure_domains: None,
809 },
810 )]
811 .into_iter()
812 .collect(),
813 };
814
815 let plan = plan_compute(&stack, Platform::Aws, Some(&settings)).expect("plan should build");
816
817 let pool = plan.pools.first().expect("general pool should exist");
818 assert!(pool.errors.is_empty());
819 assert!(pool.machines.iter().all(|machine| {
820 instance_catalog::find_instance_type(Platform::Aws, &machine.machine)
821 .is_some_and(|spec| spec.architecture == Architecture::X86_64)
822 }));
823 }
824
825 #[test]
826 fn explicit_capacity_group_requirements_are_merged_with_workloads() {
827 let mut stack = stack_with_container();
828 let cluster = ComputeCluster::new("compute".to_string())
829 .capacity_group(CapacityGroup {
830 group_id: "general".to_string(),
831 instance_type: None,
832 profile: Some(MachineProfile {
833 cpu: "4".to_string(),
834 memory_bytes: 16 * 1024 * 1024 * 1024,
835 ephemeral_storage_bytes: 20 * 1024 * 1024 * 1024,
836 architecture: Some(Architecture::X86_64),
837 gpu: None,
838 }),
839 min_size: 2,
840 max_size: 5,
841 scale_policy: None,
842 nested_virtualization: Some(true),
843 })
844 .build();
845 stack.resources.insert(
846 "compute".to_string(),
847 ResourceEntry {
848 config: Resource::new(cluster),
849 lifecycle: ResourceLifecycle::Frozen,
850 dependencies: Vec::new(),
851 remote_access: false,
852 },
853 );
854
855 let plan = plan_compute(&stack, Platform::Aws, None).expect("plan should build");
856
857 let pool = plan.pools.first().expect("general pool should exist");
858 let machine = pool
859 .selected
860 .machine()
861 .expect("AWS selection should include a machine");
862 let spec = instance_catalog::find_instance_type(Platform::Aws, machine)
863 .expect("selected machine should exist in the catalog");
864 assert!(spec.is_nested_virt_capable());
865 assert_eq!(pool.selected.min_size(), 2);
866 assert_eq!(pool.selected.max_size(), 5);
867 assert!(pool.errors.is_empty());
868 }
869
870 #[test]
871 fn nested_x86_fixed_range_pool_preserves_bounds_and_rejects_graviton() {
872 let daemon = Daemon::new("vm-runtime-loader".to_string())
873 .code(DaemonCode::Image {
874 image: "example.com/vm-runtime:latest".to_string(),
875 })
876 .cluster("vm-runtime".to_string())
877 .cpu(ResourceSpec {
878 min: "2".to_string(),
879 desired: "2".to_string(),
880 })
881 .memory(ResourceSpec {
882 min: "4Gi".to_string(),
883 desired: "4Gi".to_string(),
884 })
885 .permissions("loader".to_string())
886 .build();
887 let cluster = ComputeCluster::new("vm-runtime".to_string())
888 .capacity_group(CapacityGroup {
889 group_id: "general".to_string(),
890 instance_type: None,
891 profile: Some(MachineProfile {
892 cpu: "4".to_string(),
893 memory_bytes: 16 * 1024 * 1024 * 1024,
894 ephemeral_storage_bytes: 20 * 1024 * 1024 * 1024,
895 architecture: Some(Architecture::X86_64),
896 gpu: None,
897 }),
898 min_size: 2,
899 max_size: 2,
900 scale_policy: Some(CapacityGroupScalePolicy::Fixed {
901 machines: ComputeChoiceRange {
902 min: 1,
903 max: 5,
904 default: 2,
905 },
906 }),
907 nested_virtualization: Some(true),
908 })
909 .build();
910 let stack = Stack {
911 id: "vm-runtime".to_string(),
912 resources: [
913 (
914 "vm-runtime-loader".to_string(),
915 ResourceEntry {
916 config: Resource::new(daemon),
917 lifecycle: ResourceLifecycle::Live,
918 dependencies: Vec::new(),
919 remote_access: false,
920 },
921 ),
922 (
923 "vm-runtime".to_string(),
924 ResourceEntry {
925 config: Resource::new(cluster),
926 lifecycle: ResourceLifecycle::Frozen,
927 dependencies: Vec::new(),
928 remote_access: false,
929 },
930 ),
931 ]
932 .into_iter()
933 .collect(),
934 permissions: crate::permissions::PermissionsConfig::default(),
935 supported_platforms: None,
936 inputs: vec![],
937 };
938
939 let plan = plan_compute(&stack, Platform::Aws, None).expect("plan should build");
940 let pool = plan.pools.first().expect("general pool should exist");
941 assert_eq!(pool.recommended.machine(), Some("m8i.2xlarge"));
942 assert_eq!(pool.recommended.min_size(), 2);
943 assert_eq!(pool.recommended.max_size(), 2);
944 assert_eq!(
945 pool.scale,
946 CapacityGroupScalePolicy::Fixed {
947 machines: ComputeChoiceRange {
948 min: 1,
949 max: 5,
950 default: 2,
951 },
952 }
953 );
954 assert!(!pool
955 .machines
956 .iter()
957 .any(|option| option.machine == "m7g.2xlarge"));
958
959 let invalid_settings = ComputeSettings {
960 pools: [(
961 "general".to_string(),
962 ComputePoolSelection::Fixed {
963 machines: 2,
964 machine: Some("m7g.2xlarge".to_string()),
965 failure_domains: None,
966 },
967 )]
968 .into_iter()
969 .collect(),
970 };
971 let invalid_plan = plan_compute(&stack, Platform::Aws, Some(&invalid_settings))
972 .expect("plan should build");
973 assert!(!invalid_plan.pools[0].errors.is_empty());
974 }
975
976 #[test]
977 fn local_plan_has_no_provider_machine_choices() {
978 let stack = stack_with_container();
979
980 let plan = plan_compute(&stack, Platform::Local, None).expect("plan should build");
981
982 let pool = plan.pools.first().expect("general pool should exist");
983 assert_eq!(pool.selected.machine(), None);
984 assert!(pool.machines.is_empty());
985 assert!(pool.errors.is_empty());
986 }
987}