1use crate::{
8 instance_catalog::{self, Architecture, InstanceFamily, WorkloadRequirements},
9 CapacityGroup, CapacityGroupScalePolicy, ComputeChoiceRange, ComputePoolSelection, Container,
10 Daemon, ErrorData, FailureDomainSelection, GpuSpec, MachineProfile, Platform, ResourceSpec,
11 Stack, StackState,
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 #[serde(default, skip_serializing_if = "Vec::is_empty")]
26 pub containers: Vec<ContainerResourcePlan>,
27}
28
29#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
31#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
32#[serde(rename_all = "camelCase")]
33pub struct ContainerResourcePlan {
34 pub container_id: String,
36 pub choices: crate::ContainerResourceChoices,
38 pub cpu: ResourceSpec,
40 pub memory: ResourceSpec,
42 #[serde(default, skip_serializing_if = "Option::is_none")]
44 pub volume_size_gib: Option<u32>,
45 #[serde(default, skip_serializing_if = "Option::is_none")]
47 pub minimum_volume_size_gib: Option<u32>,
48 #[serde(default, skip_serializing_if = "Option::is_none")]
50 pub maximum_volume_size_gib: Option<u32>,
51}
52
53#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
55#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
56#[serde(rename_all = "camelCase")]
57pub struct ComputePoolPlan {
58 pub pool_id: String,
60 pub workloads: Vec<String>,
62 pub requirements: MachineProfile,
64 pub scale: CapacityGroupScalePolicy,
66 pub selected: ComputePoolSelection,
68 pub recommended: ComputePoolSelection,
70 pub machines: Vec<ComputeMachineOption>,
73 #[serde(default, skip_serializing_if = "Option::is_none")]
75 pub data_disk: Option<ComputePoolDataDiskPlan>,
76 #[serde(default, skip_serializing_if = "Vec::is_empty")]
78 pub errors: Vec<String>,
79}
80
81#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
85#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
86#[serde(rename_all = "camelCase")]
87pub struct ComputePoolDataDiskPlan {
88 pub min_gib: u32,
90 pub max_gib: u32,
92 pub default_gib: u32,
94 pub selected_gib: u32,
96}
97
98#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
100#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
101#[serde(rename_all = "camelCase")]
102pub struct ComputeMachineOption {
103 pub machine: String,
105 pub series: String,
107 pub category: InstanceFamily,
109 pub label: String,
111 pub profile: MachineProfile,
113 pub nested_virtualization: bool,
115 #[serde(default, skip_serializing_if = "Option::is_none")]
118 pub local_storage: Option<u64>,
119 pub available: bool,
121 pub unavailable_reasons: Vec<MachineUnavailableReason>,
123 pub recommended: bool,
125}
126
127#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
129#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
130#[serde(rename_all = "camelCase")]
131pub enum MachineUnavailableReason {
132 Architecture,
134 NestedVirtualization,
136 Cpu,
138 Memory,
140 EphemeralStorage,
143 Gpu,
145}
146
147pub fn default_persistent_failure_domains() -> FailureDomainSelection {
151 FailureDomainSelection {
152 spread: 1,
153 selected_failure_domains: Vec::new(),
154 }
155}
156
157pub fn plan_compute(
159 stack: &Stack,
160 platform: Platform,
161 selected_settings: Option<&crate::ComputeSettings>,
162) -> Result<ComputePlan, ErrorData> {
163 plan_compute_with_state(stack, platform, selected_settings, None)
164}
165
166pub fn plan_compute_with_state(
169 stack: &Stack,
170 platform: Platform,
171 selected_settings: Option<&crate::ComputeSettings>,
172 previous_state: Option<&StackState>,
173) -> Result<ComputePlan, ErrorData> {
174 let mut resolved_stack = stack.clone();
175 crate::container_resources::resolve_container_resources(
176 &mut resolved_stack,
177 selected_settings,
178 )?;
179 if let Some(previous_state) = previous_state {
180 crate::container_resources::retain_installed_volume_sizes(
181 &mut resolved_stack,
182 selected_settings,
183 previous_state,
184 );
185 }
186 let (minimum, maximum) = match platform {
187 Platform::Gcp => (10, 65536),
188 Platform::Azure => (1, 32767),
189 _ => (1, 65536),
190 };
191 for (id, entry) in &resolved_stack.resources {
192 let Some(storage) = entry
193 .config
194 .downcast_ref::<Container>()
195 .and_then(|container| container.persistent_storage.as_ref())
196 else {
197 continue;
198 };
199 let size = crate::container_resources::parse_volume_size_gib(&storage.size)
200 .map_err(|reason| crate::container_resources::invalid(id, "volumeSizeGib", &reason))?;
201 if size < minimum || size > maximum {
202 return Err(AlienError::new(
203 ErrorData::ContainerResourceSelectionInvalid {
204 resource_id: id.clone(),
205 dimension: "volumeSizeGib".into(),
206 reason: format!(
207 "capacity for {platform} must be between {minimum} and {maximum} GiB"
208 ),
209 },
210 ));
211 }
212 }
213 if let Some(previous_state) = previous_state {
214 for (id, entry) in &resolved_stack.resources {
215 let desired = entry
216 .config
217 .downcast_ref::<Container>()
218 .and_then(|container| container.persistent_storage.as_ref());
219 let previous = previous_state
220 .resources
221 .get(id)
222 .and_then(|resource| resource.config.downcast_ref::<Container>())
223 .and_then(|container| container.persistent_storage.as_ref());
224 if previous.is_some() && desired.is_none() {
225 return Err(crate::container_resources::invalid(
226 id,
227 "volumeSizeGib",
228 "removing installed persistent storage is unsupported",
229 ));
230 }
231 if let (Some(desired), Some(previous)) = (desired, previous) {
232 if desired.mount_path != previous.mount_path {
233 return Err(crate::container_resources::invalid(
234 id,
235 "volumeSizeGib",
236 "changing an installed persistent storage mount path is unsupported",
237 ));
238 }
239 let target = crate::container_resources::parse_volume_size_gib(&desired.size);
240 let installed = crate::container_resources::parse_volume_size_gib(&previous.size);
241 if let (Ok(target), Ok(installed)) = (target, installed) {
242 if target < installed {
243 return Err(AlienError::new(ErrorData::ContainerResourceSelectionInvalid {
244 resource_id: id.clone(), dimension: "volumeSizeGib".into(),
245 reason: format!("existing volumes cannot shrink from {installed} to {target} GiB"),
246 }));
247 }
248 }
249 }
250 }
251 }
252 let stack = &resolved_stack;
253 let mut groups = collect_workload_groups(stack)?;
254 merge_explicit_compute_groups(stack, &mut groups)?;
255
256 let mut pool_ids: Vec<String> = groups.keys().cloned().collect();
257 pool_ids.sort();
258
259 let mut pools = Vec::new();
260 for pool_id in pool_ids {
261 let mut group = groups.remove(&pool_id).expect("pool id came from map keys");
262 let mut requirements = group.requirements;
263 let selected = selected_settings.and_then(|settings| settings.pools.get(&pool_id));
264 let declared_group = stack
265 .resources
266 .values()
267 .filter_map(|entry| entry.config.downcast_ref::<crate::ComputeCluster>())
268 .flat_map(|cluster| &cluster.capacity_groups)
269 .find(|group| group.group_id == pool_id);
270 let declared_machine = declared_group.and_then(|group| group.instance_type.as_deref());
271 let machine_override = match selected {
272 Some(selection) => selection.machine(),
273 None => declared_machine,
274 };
275 if requirements.architecture.is_none() {
276 requirements.architecture = machine_override
277 .and_then(|machine| instance_catalog::find_instance_type(platform, machine))
278 .map(|spec| spec.architecture)
279 .or_else(|| {
280 previous_state.and_then(|state| {
281 state
282 .resources
283 .values()
284 .filter_map(|resource| {
285 resource.config.downcast_ref::<crate::ComputeCluster>()
286 })
287 .flat_map(|cluster| &cluster.capacity_groups)
288 .find(|group| group.group_id == pool_id)
289 .and_then(|group| group.instance_type.as_deref())
290 .and_then(|machine| {
291 instance_catalog::find_instance_type(platform, machine)
292 })
293 .map(|spec| spec.architecture)
294 })
295 });
296 }
297 if group.generated {
298 group.scale = generated_pool_scale_policy(
299 group.scale.default_min_size(),
300 group.scale.default_max_size(),
301 selected.map(ComputePoolSelection::max_size),
302 );
303 }
304 let machines = selected
305 .map(ComputePoolSelection::max_size)
306 .unwrap_or(group.scale.default_max_size())
307 .max(1);
308 let mut sizing = requirements.clone();
309 sizing.max_cpu_per_container = sizing
311 .max_cpu_per_container
312 .max(sizing.total_cpu_at_desired / f64::from(machines));
313 sizing.max_memory_per_container = sizing.max_memory_per_container.max(
314 sizing
315 .total_memory_bytes_at_desired
316 .div_ceil(u64::from(machines)),
317 );
318 let recommended = recommended_selection(
319 platform,
320 &sizing,
321 &group.scale,
322 group.requires_failure_domain,
323 group.generated,
324 machine_override,
325 )?;
326 let mut selected_choice = selected.cloned().unwrap_or_else(|| recommended.clone());
327 if selected.is_none() {
328 if let Some(declared_machine) = declared_machine {
329 match &mut selected_choice {
330 ComputePoolSelection::Fixed { machine, .. }
331 | ComputePoolSelection::Autoscale { machine, .. } => {
332 *machine = Some(declared_machine.to_string());
333 }
334 }
335 }
336 }
337 match &mut selected_choice {
341 ComputePoolSelection::Fixed { machine, .. }
342 | ComputePoolSelection::Autoscale { machine, .. } => {
343 if machine.is_none() {
344 *machine = recommended.machine().map(ToString::to_string);
345 }
346 }
347 }
348 if selected_choice.failure_domains().is_none() {
349 if let Some(default_failure_domains) = recommended.failure_domains().cloned() {
350 match &mut selected_choice {
351 ComputePoolSelection::Fixed {
352 failure_domains, ..
353 }
354 | ComputePoolSelection::Autoscale {
355 failure_domains, ..
356 } => *failure_domains = Some(default_failure_domains),
357 }
358 }
359 }
360 let mut errors = validate_compute_pool_selection(
361 platform,
362 &pool_id,
363 &selected_choice,
364 &requirements,
365 &group.scale,
366 );
367 if let Some(workload_requirements) = &group.workload_requirements {
370 if let Err(message) =
371 check_pool_capacity(platform, &pool_id, &selected_choice, workload_requirements)
372 {
373 errors.push(message);
374 }
375 }
376 let selected_data_disk_gib = selected.and_then(ComputePoolSelection::data_disk_gib);
377 let data_disk_choices = declared_group.and_then(crate::CapacityGroup::data_disk_choices);
378 let data_disk_result = match declared_group {
379 Some(group) => group.effective_data_disk_gib(selected_data_disk_gib),
380 None => match selected_data_disk_gib {
381 None | Some(0) => Ok(None),
382 Some(size) => Err(format!(
383 "pool '{pool_id}' declares no data disk, so dataDiskGib {size} cannot be chosen"
384 )),
385 },
386 };
387 if let Err(message) = &data_disk_result {
388 errors.push(message.clone());
389 }
390 let data_disk = data_disk_choices.map(|choices| ComputePoolDataDiskPlan {
391 min_gib: choices.min,
392 max_gib: choices.max,
393 default_gib: choices.default,
394 selected_gib: match &data_disk_result {
395 Ok(size) => size.unwrap_or(0),
396 Err(_) => selected_data_disk_gib.unwrap_or(choices.default),
397 },
398 });
399 let machines = machine_options(
400 platform,
401 &requirements,
402 group.workload_requirements.as_ref(),
403 selected_choice.machine(),
404 recommended.machine(),
405 )?;
406
407 pools.push(ComputePoolPlan {
408 pool_id,
409 workloads: group.workloads,
410 requirements: requirements_to_profile(&requirements),
411 scale: group.scale,
412 selected: selected_choice,
413 recommended,
414 machines,
415 data_disk,
416 errors,
417 });
418 }
419
420 let mut containers: Vec<_> = stack
421 .resources
422 .values()
423 .filter_map(|entry| entry.config.downcast_ref::<Container>())
424 .filter(|container| {
425 container.resource_choices.is_some() || container.persistent_storage.is_some()
426 })
427 .map(|container| {
428 let capacity = |storage: &crate::PersistentStorage| {
429 crate::container_resources::parse_volume_size_gib(&storage.size).map_err(|reason| {
430 crate::container_resources::invalid(&container.id, "volumeSizeGib", &reason)
431 })
432 };
433 let volume_size_gib = container
434 .persistent_storage
435 .as_ref()
436 .map(capacity)
437 .transpose()?;
438 let installed = previous_state
439 .and_then(|state| state.resources.get(&container.id))
440 .and_then(|resource| resource.config.downcast_ref::<Container>())
441 .and_then(|container| container.persistent_storage.as_ref())
442 .map(capacity)
443 .transpose()?;
444 Ok(ContainerResourcePlan {
445 container_id: container.id.clone(),
446 choices: container.resource_choices.clone().unwrap_or_default(),
447 cpu: container.cpu.clone(),
448 memory: container.memory.clone(),
449 volume_size_gib,
450 minimum_volume_size_gib: volume_size_gib
451 .map(|_| installed.unwrap_or(if platform == Platform::Gcp { 10 } else { 1 })),
452 maximum_volume_size_gib: volume_size_gib.map(|_| {
453 if platform == Platform::Azure {
454 32767
455 } else {
456 65536
457 }
458 }),
459 })
460 })
461 .collect::<Result<Vec<_>, ErrorData>>()?;
462 containers.sort_by(|a, b| a.container_id.cmp(&b.container_id));
463 Ok(ComputePlan { pools, containers })
464}
465
466#[derive(Debug, Clone)]
467struct PlannedGroup {
468 workloads: Vec<String>,
469 workload_requirements: Option<WorkloadRequirements>,
471 requirements: WorkloadRequirements,
472 scale: CapacityGroupScalePolicy,
473 requires_failure_domain: bool,
474 generated: bool,
477}
478
479pub const GENERATED_POOL_MAX_MACHINES: u32 = 100;
484
485pub fn generated_pool_scale_policy(
497 default_min: u32,
498 default_max: u32,
499 selected_max: Option<u32>,
500) -> CapacityGroupScalePolicy {
501 let offered = GENERATED_POOL_MAX_MACHINES;
502 let ceiling = offered.max(selected_max.unwrap_or(0));
503 let default_min = default_min.min(offered);
504 CapacityGroupScalePolicy::Autoscale {
505 min: ComputeChoiceRange {
506 min: default_min,
507 max: ceiling,
508 default: default_min,
509 },
510 max: ComputeChoiceRange {
511 min: default_min.max(1),
512 max: ceiling,
513 default: default_max.clamp(default_min.max(1), offered),
514 },
515 }
516}
517
518pub fn check_pool_capacity(
522 platform: Platform,
523 pool_id: &str,
524 selection: &ComputePoolSelection,
525 requirements: &WorkloadRequirements,
526) -> std::result::Result<(), String> {
527 let Some(spec) = selection
528 .machine()
529 .and_then(|machine| instance_catalog::find_instance_type(platform, machine))
530 else {
531 return Ok(());
533 };
534 let machines = selection.max_size();
535 let per_machine_cpu = instance_catalog::allocatable_cpu(spec);
536 let per_machine_memory = instance_catalog::allocatable_memory_bytes(spec);
537 if per_machine_cpu + f64::EPSILON < requirements.max_cpu_per_container
538 || per_machine_memory < requirements.max_memory_per_container
539 {
540 return Err(format!(
541 "Pool '{pool_id}' machine '{}' cannot fit one replica after reserving host resources",
542 spec.name,
543 ));
544 }
545 let cpu = f64::from(machines) * per_machine_cpu;
546 let memory = u64::from(machines) * per_machine_memory;
547 if cpu + f64::EPSILON >= requirements.total_cpu_at_desired
548 && memory >= requirements.total_memory_bytes_at_desired
549 {
550 return Ok(());
551 }
552 const GIB: f64 = 1024.0 * 1024.0 * 1024.0;
553 Err(format!(
554 "Pool '{pool_id}' is too small for its workloads: {machines} x {} has {cpu:.2} vCPU and {:.1} GiB, and the workloads request {:.2} vCPU and {:.1} GiB at their desired replica counts",
555 spec.name,
556 memory as f64 / GIB,
557 requirements.total_cpu_at_desired,
558 requirements.total_memory_bytes_at_desired as f64 / GIB,
559 ))
560}
561
562fn collect_workload_groups(stack: &Stack) -> Result<HashMap<String, PlannedGroup>, ErrorData> {
563 let mut groups: HashMap<String, Vec<Workload>> = HashMap::new();
564
565 for entry in stack.resources.values() {
566 if let Some(container) = entry.config.downcast_ref::<Container>() {
567 groups
568 .entry(
569 container
570 .pool
571 .clone()
572 .unwrap_or_else(|| needed_container_pool(container).to_string()),
573 )
574 .or_default()
575 .push(Workload::from_container(container)?);
576 }
577 if let Some(daemon) = entry.config.downcast_ref::<Daemon>() {
578 if daemon.cluster.is_some() {
579 groups
580 .entry(daemon.pool.clone().unwrap_or_else(|| "general".to_string()))
581 .or_default()
582 .push(Workload::from_daemon(daemon)?);
583 }
584 }
585 }
586
587 let mut planned = HashMap::new();
588 for (pool_id, workloads) in groups {
589 let requirements = aggregate_workloads(&workloads);
590 let requires_failure_domain = workloads
591 .iter()
592 .any(|workload| workload.requires_failure_domain);
593 let min_size = default_min_machines(&requirements);
594 let max_size = default_max_machines(&requirements);
595 planned.insert(
596 pool_id,
597 PlannedGroup {
598 workloads: workloads.into_iter().map(|w| w.id).collect(),
599 workload_requirements: Some(requirements.clone()),
600 scale: CapacityGroupScalePolicy::from_selected_bounds(min_size, max_size),
602 requirements,
603 requires_failure_domain,
604 generated: true,
605 },
606 );
607 }
608 Ok(planned)
609}
610
611fn merge_explicit_compute_groups(
612 stack: &Stack,
613 groups: &mut HashMap<String, PlannedGroup>,
614) -> Result<(), ErrorData> {
615 for entry in stack.resources.values() {
616 let Some(cluster) = entry.config.downcast_ref::<crate::ComputeCluster>() else {
617 continue;
618 };
619 for group in &cluster.capacity_groups {
620 let explicit_requirements = profile_to_requirements(
621 group.profile.as_ref(),
622 group.nested_virtualization.unwrap_or(false),
623 );
624 let scale = group.scale_policy.clone().unwrap_or_else(|| {
625 CapacityGroupScalePolicy::from_selected_bounds(group.min_size, group.max_size)
626 });
627 groups
628 .entry(group.group_id.clone())
629 .and_modify(|planned| {
630 if group.instance_type.is_none() {
631 merge_requirements(&mut planned.requirements, &explicit_requirements);
632 } else {
633 planned.requirements.architecture = planned
640 .requirements
641 .architecture
642 .or(explicit_requirements.architecture);
643 planned.requirements.nested_virt |= explicit_requirements.nested_virt;
644 }
645 planned.scale = merge_scale_policy(&planned.scale, &scale);
646 planned.generated = false;
647 })
648 .or_insert_with(|| PlannedGroup {
649 workloads: Vec::new(),
650 workload_requirements: None,
651 scale,
652 requirements: explicit_requirements,
653 requires_failure_domain: false,
654 generated: false,
655 });
656 }
657 }
658 Ok(())
659}
660
661fn recommended_selection(
662 platform: Platform,
663 requirements: &WorkloadRequirements,
664 scale: &CapacityGroupScalePolicy,
665 requires_failure_domain: bool,
666 generated: bool,
667 machine_override: Option<&str>,
668) -> Result<ComputePoolSelection, ErrorData> {
669 let machine = match platform {
670 Platform::Aws | Platform::Gcp | Platform::Azure => Some(
671 match instance_catalog::select_instance_type(platform, requirements) {
672 Ok(selection) => selection.instance_type.to_string(),
673 Err(message) => {
674 let fallback = machine_override
678 .and_then(|machine| instance_catalog::find_instance_type(platform, machine))
679 .filter(|spec| {
680 instance_satisfies(
681 spec,
682 requirements,
683 requirements.architecture.unwrap_or(spec.architecture),
684 )
685 });
686 fallback.map(|spec| spec.name.to_string()).ok_or_else(|| {
687 AlienError::new(ErrorData::GenericError {
688 message: format!("Failed to select {platform} machine: {message}"),
689 })
690 })?
691 }
692 },
693 ),
694 Platform::Local | Platform::Kubernetes | Platform::Machines | Platform::Test => None,
695 };
696
697 let failure_domains = (requires_failure_domain
698 && matches!(platform, Platform::Aws | Platform::Gcp | Platform::Azure))
699 .then(default_persistent_failure_domains);
700
701 match scale {
702 CapacityGroupScalePolicy::Fixed { machines } => Ok(ComputePoolSelection::Fixed {
703 machines: machines.default.max(1),
704 machine,
705 failure_domains,
706 data_disk_gib: None,
707 }),
708 CapacityGroupScalePolicy::Autoscale { min, max }
710 if generated && min.default == max.default =>
711 {
712 Ok(ComputePoolSelection::Fixed {
713 machines: min.default.max(1),
714 machine,
715 failure_domains,
716 data_disk_gib: None,
717 })
718 }
719 CapacityGroupScalePolicy::Autoscale { min, max } => Ok(ComputePoolSelection::Autoscale {
720 min: min.default,
721 max: max.default.max(min.default),
722 machine,
723 failure_domains,
724 data_disk_gib: None,
725 }),
726 }
727}
728
729pub fn validate_compute_pool_selection(
732 platform: Platform,
733 pool_id: &str,
734 selection: &ComputePoolSelection,
735 requirements: &WorkloadRequirements,
736 scale: &CapacityGroupScalePolicy,
737) -> Vec<String> {
738 let mut errors = Vec::new();
739 if let Err(message) = selection.validate() {
740 errors.push(message);
741 }
742 if let Err(message) = validate_selection_against_scale(selection, scale) {
743 errors.push(format!("Pool '{pool_id}' {message}"));
744 }
745 if matches!(platform, Platform::Aws | Platform::Gcp | Platform::Azure) {
746 match selection.machine() {
747 Some(machine) => match instance_catalog::find_instance_type(platform, machine) {
748 Some(spec) => {
749 let architecture = requirements.architecture.unwrap_or(spec.architecture);
750 if !instance_satisfies(spec, requirements, architecture) {
751 errors.push(format!(
752 "{} machine '{}' does not satisfy pool '{}' requirements",
753 platform, machine, pool_id
754 ));
755 }
756 }
757 None => errors.push(format!(
758 "Unknown {} machine '{}' for pool '{}'",
759 platform, machine, pool_id
760 )),
761 },
762 None => errors.push(format!(
763 "Pool '{}' requires a provider machine on {}",
764 pool_id, platform
765 )),
766 }
767 }
768 errors
769}
770
771pub fn capacity_group_requirements(group: &CapacityGroup) -> WorkloadRequirements {
773 profile_to_requirements(
774 group.profile.as_ref(),
775 group.nested_virtualization.unwrap_or(false),
776 )
777}
778
779fn machine_options(
780 platform: Platform,
781 requirements: &WorkloadRequirements,
782 workload_requirements: Option<&WorkloadRequirements>,
783 selected_machine: Option<&str>,
784 recommended_machine: Option<&str>,
785) -> Result<Vec<ComputeMachineOption>, ErrorData> {
786 if !matches!(platform, Platform::Aws | Platform::Gcp | Platform::Azure) {
787 return Ok(Vec::new());
788 }
789 let resolved_architecture = requirements
790 .architecture
791 .or_else(|| {
792 selected_machine.and_then(|machine| {
793 instance_catalog::find_instance_type(platform, machine)
794 .map(|spec| spec.architecture)
795 })
796 })
797 .or_else(|| {
798 recommended_machine
799 .and_then(|machine| instance_catalog::find_instance_type(platform, machine))
800 .map(|spec| spec.architecture)
801 })
802 .ok_or_else(|| {
803 AlienError::new(ErrorData::GenericError {
804 message: format!("Selected {platform} machine has no CPU architecture"),
805 })
806 })?;
807
808 Ok(instance_catalog::catalog_for_platform(platform)
809 .into_iter()
810 .map(|spec| {
811 let unavailable_reasons = unavailable_reasons(
812 spec,
813 requirements,
814 resolved_architecture,
815 match workload_requirements {
816 Some(workloads) => Fit::OneReplica(workloads),
817 None => Fit::Hardware,
818 },
819 );
820 ComputeMachineOption {
821 machine: spec.name.to_string(),
822 series: spec.series.to_string(),
823 category: spec.family,
824 label: spec.label.to_string(),
825 profile: spec
826 .to_machine_profile_for_storage(requirements.max_ephemeral_storage_bytes),
827 nested_virtualization: spec.is_nested_virt_capable(),
828 local_storage: (spec.local_disk_bytes > 0).then_some(spec.local_disk_bytes),
829 available: unavailable_reasons.is_empty(),
830 unavailable_reasons,
831 recommended: Some(spec.name) == recommended_machine
832 || Some(spec.name) == selected_machine,
833 }
834 })
835 .collect())
836}
837
838fn instance_satisfies(
839 spec: &instance_catalog::InstanceTypeSpec,
840 requirements: &WorkloadRequirements,
841 resolved_architecture: Architecture,
842) -> bool {
843 unavailable_reasons(spec, requirements, resolved_architecture, Fit::Hardware).is_empty()
844}
845
846#[derive(Clone, Copy)]
848enum Fit<'a> {
849 Hardware,
852 OneReplica(&'a WorkloadRequirements),
856}
857
858fn unavailable_reasons(
861 spec: &instance_catalog::InstanceTypeSpec,
862 requirements: &WorkloadRequirements,
863 resolved_architecture: Architecture,
864 fit: Fit<'_>,
865) -> Vec<MachineUnavailableReason> {
866 let mut reasons = Vec::new();
867 if spec.architecture != resolved_architecture {
868 reasons.push(MachineUnavailableReason::Architecture);
869 }
870 if requirements.nested_virt && !spec.is_nested_virt_capable() {
871 reasons.push(MachineUnavailableReason::NestedVirtualization);
872 }
873 let mut cpu_fits = spec.vcpu >= requirements.max_cpu_per_container.ceil() as u32;
874 let mut memory_fits = spec.memory_bytes >= requirements.max_memory_per_container;
875 if let Fit::OneReplica(workloads) = fit {
876 cpu_fits &= instance_catalog::allocatable_cpu(spec) + f64::EPSILON
877 >= workloads.max_cpu_per_container;
878 memory_fits &=
879 instance_catalog::allocatable_memory_bytes(spec) >= workloads.max_memory_per_container;
880 }
881 if !cpu_fits {
882 reasons.push(MachineUnavailableReason::Cpu);
883 }
884 if !memory_fits {
885 reasons.push(MachineUnavailableReason::Memory);
886 }
887 let storage_fits = if spec.has_configurable_ephemeral_storage() {
888 crate::instance_catalog::max_configurable_ephemeral_storage_bytes(spec.platform)
889 .is_some_and(|max| requirements.max_ephemeral_storage_bytes <= max)
890 } else {
891 spec.ephemeral_storage_bytes >= requirements.max_ephemeral_storage_bytes
892 };
893 if !storage_fits {
894 reasons.push(MachineUnavailableReason::EphemeralStorage);
895 }
896 let gpu_fits = match (&requirements.gpu, spec.gpu) {
897 (Some(required), Some(actual)) => {
898 (required.gpu_type == "any" || required.gpu_type == actual.gpu_type)
899 && actual.count >= required.count
900 }
901 (Some(_), None) => false,
902 (None, _) => true,
903 };
904 if !gpu_fits {
905 reasons.push(MachineUnavailableReason::Gpu);
906 }
907 reasons
908}
909
910#[derive(Debug, Clone)]
911struct Workload {
912 id: String,
913 cpu: f64,
914 memory_bytes: u64,
915 desired_replicas: f64,
916 max_replicas: f64,
917 ephemeral_storage_bytes: u64,
918 gpu: Option<GpuSpec>,
919 requires_failure_domain: bool,
920}
921
922impl Workload {
923 fn from_container(container: &Container) -> Result<Self, ErrorData> {
924 let cpu = parse_cpu(&container.id, &container.cpu)?;
925 let memory_bytes = parse_memory(&container.id, &container.memory)?;
926 let desired_replicas = container
927 .autoscaling
928 .as_ref()
929 .map(|a| a.desired)
930 .or(container.replicas)
931 .unwrap_or(1) as f64;
932 let max_replicas = container
933 .autoscaling
934 .as_ref()
935 .map(|a| a.max)
936 .or(container.replicas)
937 .unwrap_or(1) as f64;
938 let ephemeral_storage_bytes = container
939 .ephemeral_storage
940 .as_deref()
941 .map(instance_catalog::parse_memory_bytes)
942 .transpose()
943 .map_err(|message| {
944 AlienError::new(ErrorData::GenericError {
945 message: format!(
946 "Failed to parse ephemeral storage for '{}': {message}",
947 container.id
948 ),
949 })
950 })?
951 .unwrap_or(0);
952
953 Ok(Self {
954 id: container.id.clone(),
955 cpu,
956 memory_bytes,
957 desired_replicas,
958 max_replicas,
959 ephemeral_storage_bytes,
960 gpu: container.gpu.as_ref().map(|gpu| GpuSpec {
961 gpu_type: gpu.gpu_type.clone(),
962 count: gpu.count,
963 }),
964 requires_failure_domain: container.stateful && container.persistent_storage.is_some(),
965 })
966 }
967
968 fn from_daemon(daemon: &Daemon) -> Result<Self, ErrorData> {
969 Ok(Self {
970 id: daemon.id.clone(),
971 cpu: parse_cpu(&daemon.id, &daemon.cpu)?,
972 memory_bytes: parse_memory(&daemon.id, &daemon.memory)?,
973 desired_replicas: 1.0,
974 max_replicas: 1.0,
975 ephemeral_storage_bytes: 0,
976 gpu: None,
977 requires_failure_domain: false,
978 })
979 }
980}
981
982fn parse_cpu(resource_id: &str, spec: &ResourceSpec) -> Result<f64, ErrorData> {
983 instance_catalog::parse_cpu(&spec.desired).map_err(|message| {
984 AlienError::new(ErrorData::GenericError {
985 message: format!(
986 "Failed to parse CPU requirement '{}' for '{}': {message}",
987 spec.desired, resource_id
988 ),
989 })
990 })
991}
992
993fn parse_memory(resource_id: &str, spec: &ResourceSpec) -> Result<u64, ErrorData> {
994 instance_catalog::parse_memory_bytes(&spec.desired).map_err(|message| {
995 AlienError::new(ErrorData::GenericError {
996 message: format!(
997 "Failed to parse memory requirement '{}' for '{}': {message}",
998 spec.desired, resource_id
999 ),
1000 })
1001 })
1002}
1003
1004fn aggregate_workloads(workloads: &[Workload]) -> WorkloadRequirements {
1005 let mut requirements = default_requirements();
1006 requirements.total_cpu_at_desired = 0.0;
1007 requirements.total_memory_bytes_at_desired = 0;
1008 requirements.total_cpu_at_max = 0.0;
1009 requirements.total_memory_bytes_at_max = 0;
1010 requirements.max_cpu_per_container = 0.0;
1011 requirements.max_memory_per_container = 0;
1012 requirements.max_ephemeral_storage_bytes = 0;
1013 requirements.gpu = None;
1014
1015 for workload in workloads {
1016 requirements.total_cpu_at_desired += workload.cpu * workload.desired_replicas;
1017 requirements.total_cpu_at_max += workload.cpu * workload.max_replicas;
1018 requirements.total_memory_bytes_at_desired +=
1019 (workload.memory_bytes as f64 * workload.desired_replicas) as u64;
1020 requirements.total_memory_bytes_at_max +=
1021 (workload.memory_bytes as f64 * workload.max_replicas) as u64;
1022 requirements.max_cpu_per_container = requirements.max_cpu_per_container.max(workload.cpu);
1023 requirements.max_memory_per_container = requirements
1024 .max_memory_per_container
1025 .max(workload.memory_bytes);
1026 requirements.max_ephemeral_storage_bytes = requirements
1027 .max_ephemeral_storage_bytes
1028 .max(workload.ephemeral_storage_bytes);
1029 if requirements.gpu.is_none() {
1030 requirements.gpu = workload.gpu.clone();
1031 }
1032 }
1033 requirements
1034}
1035
1036fn default_requirements() -> WorkloadRequirements {
1037 WorkloadRequirements {
1038 total_cpu_at_desired: 1.0,
1039 total_memory_bytes_at_desired: 2 * 1024 * 1024 * 1024,
1040 total_cpu_at_max: 1.0,
1041 total_memory_bytes_at_max: 2 * 1024 * 1024 * 1024,
1042 max_cpu_per_container: 1.0,
1043 max_memory_per_container: 2 * 1024 * 1024 * 1024,
1044 max_ephemeral_storage_bytes: 0,
1045 gpu: None,
1046 architecture: None,
1047 nested_virt: false,
1048 }
1049}
1050
1051fn profile_to_requirements(
1052 profile: Option<&MachineProfile>,
1053 nested_virt: bool,
1054) -> WorkloadRequirements {
1055 let Some(profile) = profile else {
1056 return WorkloadRequirements {
1057 nested_virt,
1058 ..default_requirements()
1059 };
1060 };
1061 let cpu = instance_catalog::parse_cpu(&profile.cpu).unwrap_or(1.0);
1062 WorkloadRequirements {
1063 total_cpu_at_desired: cpu,
1064 total_memory_bytes_at_desired: profile.memory_bytes,
1065 total_cpu_at_max: cpu,
1066 total_memory_bytes_at_max: profile.memory_bytes,
1067 max_cpu_per_container: cpu,
1068 max_memory_per_container: profile.memory_bytes,
1069 max_ephemeral_storage_bytes: profile.ephemeral_storage_bytes,
1070 gpu: profile.gpu.clone(),
1071 architecture: profile.architecture,
1072 nested_virt,
1073 }
1074}
1075
1076fn merge_requirements(existing: &mut WorkloadRequirements, declared: &WorkloadRequirements) {
1077 existing.total_cpu_at_desired = existing
1078 .total_cpu_at_desired
1079 .max(declared.total_cpu_at_desired);
1080 existing.total_memory_bytes_at_desired = existing
1081 .total_memory_bytes_at_desired
1082 .max(declared.total_memory_bytes_at_desired);
1083 existing.total_cpu_at_max = existing.total_cpu_at_max.max(declared.total_cpu_at_max);
1084 existing.total_memory_bytes_at_max = existing
1085 .total_memory_bytes_at_max
1086 .max(declared.total_memory_bytes_at_max);
1087 existing.max_cpu_per_container = existing
1088 .max_cpu_per_container
1089 .max(declared.max_cpu_per_container);
1090 existing.max_memory_per_container = existing
1091 .max_memory_per_container
1092 .max(declared.max_memory_per_container);
1093 existing.max_ephemeral_storage_bytes = existing
1094 .max_ephemeral_storage_bytes
1095 .max(declared.max_ephemeral_storage_bytes);
1096 if existing.gpu.is_none() {
1097 existing.gpu = declared.gpu.clone();
1098 }
1099 existing.nested_virt |= declared.nested_virt;
1100 if existing.architecture.is_none() {
1101 existing.architecture = declared.architecture;
1102 }
1103}
1104
1105fn requirements_to_profile(requirements: &WorkloadRequirements) -> MachineProfile {
1106 MachineProfile {
1107 cpu: requirements.max_cpu_per_container.to_string(),
1108 memory_bytes: requirements.max_memory_per_container,
1109 ephemeral_storage_bytes: requirements.max_ephemeral_storage_bytes,
1110 architecture: requirements.architecture,
1111 gpu: requirements.gpu.clone(),
1112 data_disk: None,
1113 }
1114}
1115
1116fn merge_scale_policy(
1117 existing: &CapacityGroupScalePolicy,
1118 declared: &CapacityGroupScalePolicy,
1119) -> CapacityGroupScalePolicy {
1120 match (existing, declared) {
1121 (
1122 CapacityGroupScalePolicy::Fixed {
1123 machines: existing_machines,
1124 },
1125 CapacityGroupScalePolicy::Fixed {
1126 machines: declared_machines,
1127 },
1128 ) => CapacityGroupScalePolicy::Fixed {
1129 machines: merge_choice_range(existing_machines, declared_machines),
1130 },
1131 (_, declared) => declared.clone(),
1132 }
1133}
1134
1135fn merge_choice_range(
1136 existing: &ComputeChoiceRange,
1137 declared: &ComputeChoiceRange,
1138) -> ComputeChoiceRange {
1139 ComputeChoiceRange {
1140 min: existing.min.max(declared.min),
1141 max: existing.max.max(declared.max),
1142 default: declared.default,
1143 }
1144}
1145
1146fn validate_selection_against_scale(
1147 selection: &ComputePoolSelection,
1148 scale: &CapacityGroupScalePolicy,
1149) -> std::result::Result<(), String> {
1150 match (selection, scale) {
1153 (
1154 ComputePoolSelection::Fixed { machines, .. },
1155 CapacityGroupScalePolicy::Fixed { machines: allowed },
1156 ) => check_machine_count("fixed machine count", *machines, allowed.min, allowed.max),
1157 (
1158 ComputePoolSelection::Autoscale { min, max, .. },
1159 CapacityGroupScalePolicy::Autoscale {
1160 min: allowed_min,
1161 max: allowed_max,
1162 },
1163 ) => {
1164 check_machine_count("autoscale minimum", *min, allowed_min.min, allowed_min.max)?;
1165 check_machine_count("autoscale maximum", *max, allowed_max.min, allowed_max.max)
1166 }
1167 (
1168 ComputePoolSelection::Fixed { machines, .. },
1169 CapacityGroupScalePolicy::Autoscale {
1170 min: allowed_min,
1171 max: allowed_max,
1172 },
1173 ) => {
1174 let lowest = allowed_min.min.max(allowed_max.min);
1175 let highest = allowed_min.max.min(allowed_max.max);
1176 if lowest > highest {
1177 return Err(format!(
1178 "must use autoscale mode (minimum {}-{}, maximum {}-{})",
1179 allowed_min.min, allowed_min.max, allowed_max.min, allowed_max.max
1180 ));
1181 }
1182 check_machine_count("fixed machine count", *machines, lowest, highest)
1183 }
1184 (
1185 ComputePoolSelection::Autoscale { min, max, .. },
1186 CapacityGroupScalePolicy::Fixed { machines: allowed },
1187 ) => {
1188 if min != max {
1189 return Err(format!(
1190 "must use fixed mode with {}-{} machines; autoscale {min}-{max} is not allowed",
1191 allowed.min, allowed.max
1192 ));
1193 }
1194 check_machine_count("machine count", *min, allowed.min, allowed.max)
1195 }
1196 }
1197}
1198
1199fn check_machine_count(
1200 label: &str,
1201 value: u32,
1202 lowest: u32,
1203 highest: u32,
1204) -> std::result::Result<(), String> {
1205 if (lowest..=highest).contains(&value) {
1206 Ok(())
1207 } else {
1208 Err(format!(
1209 "{label} {value} is outside the allowed range {lowest}-{highest}"
1210 ))
1211 }
1212}
1213
1214fn needed_container_pool(container: &Container) -> &'static str {
1215 if container.stateful && container.persistent_storage.is_some() {
1216 return "stateful";
1217 }
1218 if container.gpu.is_some() {
1219 return "gpu";
1220 }
1221 if let Some(storage) = &container.ephemeral_storage {
1222 if instance_catalog::parse_memory_bytes(storage).unwrap_or(0) > 200 * 1024 * 1024 * 1024 {
1223 return "storage";
1224 }
1225 }
1226 "general"
1227}
1228
1229fn default_min_machines(requirements: &WorkloadRequirements) -> u32 {
1230 if requirements.total_cpu_at_desired > 0.0 || requirements.total_memory_bytes_at_desired > 0 {
1231 1
1232 } else {
1233 0
1234 }
1235}
1236
1237fn default_max_machines(requirements: &WorkloadRequirements) -> u32 {
1238 let min = default_min_machines(requirements);
1239 let by_cpu =
1240 (requirements.total_cpu_at_max / requirements.max_cpu_per_container.max(1.0)).ceil() as u32;
1241 let by_mem = requirements
1242 .total_memory_bytes_at_max
1243 .div_ceil(requirements.max_memory_per_container.max(1)) as u32;
1244 min.max(by_cpu).max(by_mem).max(1)
1245}
1246
1247#[cfg(test)]
1248mod tests {
1249 use super::*;
1250 use crate::{
1251 instance_catalog::Architecture, CapacityGroup, CapacityGroupScalePolicy,
1252 ComputeChoiceRange, ComputeCluster, ComputeSettings, ContainerCode, DaemonCode, Resource,
1253 ResourceEntry, ResourceLifecycle, Stack, Storage, Worker, WorkerCode,
1254 };
1255
1256 #[test]
1257 fn deployment_resources_drive_machine_planning_on_every_cloud() {
1258 let mut stack = stack_with_container();
1259 let container = stack
1260 .resources
1261 .get_mut("api")
1262 .unwrap()
1263 .config
1264 .downcast_mut::<Container>()
1265 .unwrap();
1266 container.resource_choices = Some(crate::ContainerResourceChoices {
1267 cpu: Some(crate::ResourceChoiceRange {
1268 min: "0.5".into(),
1269 max: "8".into(),
1270 default: "2".into(),
1271 }),
1272 memory: Some(crate::ResourceChoiceRange {
1273 min: "512Mi".into(),
1274 max: "16Gi".into(),
1275 default: "4Gi".into(),
1276 }),
1277 });
1278 let settings: ComputeSettings = serde_json::from_value(serde_json::json!({
1279 "pools": { "general": { "mode": "fixed", "machines": 1 } },
1280 "containers": { "api": { "cpu": 8, "memory": "16Gi" } }
1281 }))
1282 .unwrap();
1283 for platform in [Platform::Aws, Platform::Gcp, Platform::Azure] {
1284 let default_plan = plan_compute(&stack, platform, None).unwrap();
1285 let selected_plan = plan_compute(&stack, platform, Some(&settings)).unwrap();
1286 let pool = &selected_plan.pools[0];
1287 assert!(pool.errors.is_empty(), "{platform}: {:?}", pool.errors);
1288 assert_eq!(pool.requirements.cpu, "8");
1289 assert_eq!(pool.requirements.memory_bytes, 16 * 1024 * 1024 * 1024);
1290 assert_ne!(
1291 pool.selected.machine(),
1292 default_plan.pools[0].selected.machine()
1293 );
1294 assert_eq!(selected_plan.containers[0].cpu.min, "8");
1295 assert_eq!(selected_plan.containers[0].memory.desired, "16Gi");
1296 assert_eq!(pool.selected.min_size(), 1);
1297 assert_eq!(pool.selected.max_size(), 1);
1298 }
1299 assert_eq!(
1300 settings.pools["general"].machine(),
1301 None,
1302 "resolving a plan must preserve automatic machine intent"
1303 );
1304 let original = stack.resources["api"]
1305 .config
1306 .downcast_ref::<Container>()
1307 .unwrap();
1308 assert_eq!(
1309 original.cpu.desired, "2",
1310 "planning must not mutate release defaults"
1311 );
1312 }
1313
1314 #[test]
1315 fn explicit_machine_can_fit_a_low_cpu_high_memory_workload() {
1316 let mut stack = stack_with_container();
1317 let container = stack
1318 .resources
1319 .get_mut("api")
1320 .unwrap()
1321 .config
1322 .downcast_mut::<Container>()
1323 .unwrap();
1324 container.cpu = ResourceSpec {
1325 min: "0.5".into(),
1326 desired: "0.5".into(),
1327 };
1328 container.memory = ResourceSpec {
1329 min: "8Gi".into(),
1330 desired: "8Gi".into(),
1331 };
1332 let settings: ComputeSettings = serde_json::from_value(serde_json::json!({
1333 "pools": {"general":{"mode":"fixed","machines":1,"machine":"n2-standard-4"}}
1334 }))
1335 .unwrap();
1336 let plan = plan_compute(&stack, Platform::Gcp, Some(&settings)).unwrap();
1337 assert!(
1338 plan.pools[0].errors.is_empty(),
1339 "{:?}",
1340 plan.pools[0].errors
1341 );
1342 assert_eq!(plan.pools[0].selected.machine(), Some("n2-standard-4"));
1343 }
1344
1345 #[test]
1346 fn pin_must_leave_host_capacity_for_one_replica() {
1347 let mut stack = stack_with_container();
1348 stack
1349 .resources
1350 .get_mut("api")
1351 .unwrap()
1352 .config
1353 .downcast_mut::<Container>()
1354 .unwrap()
1355 .cpu = ResourceSpec {
1356 min: "4".into(),
1357 desired: "4".into(),
1358 };
1359 let settings: ComputeSettings = serde_json::from_value(serde_json::json!({
1360 "pools":{"general":{"mode":"fixed","machines":1,"machine":"t3.xlarge"}}
1361 }))
1362 .unwrap();
1363 let plan = plan_compute(&stack, Platform::Aws, Some(&settings)).unwrap();
1364 assert!(
1365 plan.pools[0]
1366 .errors
1367 .iter()
1368 .any(|error| error.contains("host resources")),
1369 "{:?}",
1370 plan.pools[0].errors
1371 );
1372 }
1373
1374 #[test]
1375 fn valid_pin_does_not_require_automatic_recommendation_headroom() {
1376 let machine = instance_catalog::catalog_for_platform(Platform::Gcp)
1377 .into_iter()
1378 .max_by_key(|machine| machine.memory_bytes)
1379 .unwrap();
1380 let mut stack = stack_with_container();
1381 let container = stack
1382 .resources
1383 .get_mut("api")
1384 .unwrap()
1385 .config
1386 .downcast_mut::<Container>()
1387 .unwrap();
1388 container.cpu = ResourceSpec {
1389 min: "0.5".into(),
1390 desired: "0.5".into(),
1391 };
1392 let memory = instance_catalog::allocatable_memory_bytes(machine).to_string();
1393 container.memory = ResourceSpec {
1394 min: memory.clone(),
1395 desired: memory,
1396 };
1397 assert!(plan_compute(&stack, Platform::Gcp, None).is_err());
1398 let settings: ComputeSettings = serde_json::from_value(serde_json::json!({
1399 "pools":{"general":{"mode":"fixed","machines":1,"machine":machine.name}}
1400 }))
1401 .unwrap();
1402 let plan = plan_compute(&stack, Platform::Gcp, Some(&settings)).unwrap();
1403 assert!(
1404 plan.pools[0].errors.is_empty(),
1405 "{:?}",
1406 plan.pools[0].errors
1407 );
1408 assert_eq!(plan.pools[0].selected.machine(), Some(machine.name));
1409 }
1410
1411 #[test]
1412 fn invalid_deployment_resources_fail_before_capacity_planning() {
1413 let mut stack = stack_with_container();
1414 stack
1415 .resources
1416 .get_mut("api")
1417 .unwrap()
1418 .config
1419 .downcast_mut::<Container>()
1420 .unwrap()
1421 .resource_choices = Some(crate::ContainerResourceChoices {
1422 cpu: Some(crate::ResourceChoiceRange {
1423 min: "0.5".into(),
1424 max: "4".into(),
1425 default: "2".into(),
1426 }),
1427 memory: Some(crate::ResourceChoiceRange {
1428 min: "512Mi".into(),
1429 max: "8Gi".into(),
1430 default: "4Gi".into(),
1431 }),
1432 });
1433 for selection in [
1434 serde_json::json!({"api":{"cpu": 0}}),
1435 serde_json::json!({"api":{"cpu": 5}}),
1436 serde_json::json!({"api":{"memory": "16Gi"}}),
1437 serde_json::json!({"api":{"memory": "NaNGi"}}),
1438 serde_json::json!({"api":{"memory": "-1Gi"}}),
1439 serde_json::json!({"missing":{"cpu": 1}}),
1440 ] {
1441 let settings: ComputeSettings =
1442 serde_json::from_value(serde_json::json!({"containers": selection})).unwrap();
1443 let error = plan_compute(&stack, Platform::Aws, Some(&settings)).unwrap_err();
1444 assert_eq!(error.code, "CONTAINER_RESOURCE_SELECTION_INVALID");
1445 }
1446 }
1447
1448 #[test]
1449 fn deployment_volume_selection_is_applied_without_changing_release_defaults() {
1450 let mut release = stack_with_container();
1451 release
1452 .resources
1453 .get_mut("api")
1454 .unwrap()
1455 .config
1456 .downcast_mut::<Container>()
1457 .unwrap()
1458 .persistent_storage = Some(crate::PersistentStorage {
1459 size: "20Gi".into(),
1460 mount_path: "/data".into(),
1461 backups: crate::VolumeBackups::default(),
1462 encryption_key: None,
1463 });
1464 let settings: ComputeSettings = serde_json::from_value(serde_json::json!({
1465 "containers": {"api": {"volumeSizeGib": 40}}
1466 }))
1467 .unwrap();
1468 let mut deployment = release.clone();
1469 crate::container_resources::resolve_container_resources(&mut deployment, Some(&settings))
1470 .unwrap();
1471 let volume = |stack: &Stack| {
1472 stack.resources["api"]
1473 .config
1474 .downcast_ref::<Container>()
1475 .unwrap()
1476 .persistent_storage
1477 .as_ref()
1478 .unwrap()
1479 .size
1480 .clone()
1481 };
1482 assert_eq!(volume(&deployment), "40Gi");
1483 assert_eq!(volume(&release), "20Gi");
1484 let mut equivalent = release.clone();
1485 equivalent
1486 .resources
1487 .get_mut("api")
1488 .unwrap()
1489 .config
1490 .downcast_mut::<Container>()
1491 .unwrap()
1492 .persistent_storage
1493 .as_mut()
1494 .unwrap()
1495 .size = "1Ti".into();
1496 let same_capacity: ComputeSettings = serde_json::from_value(serde_json::json!({
1497 "containers": {"api": {"volumeSizeGib": 1024}}
1498 }))
1499 .unwrap();
1500 crate::container_resources::resolve_container_resources(
1501 &mut equivalent,
1502 Some(&same_capacity),
1503 )
1504 .unwrap();
1505 assert_eq!(
1506 volume(&equivalent),
1507 "1Ti",
1508 "equivalent selections preserve PVC quantities"
1509 );
1510 crate::container_resources::resolve_container_resources(&mut deployment, Some(&settings))
1511 .unwrap();
1512 assert_eq!(volume(&deployment), "40Gi");
1513 let smaller_initial: ComputeSettings = serde_json::from_value(serde_json::json!({
1514 "containers": {"api": {"volumeSizeGib": 10}}
1515 }))
1516 .unwrap();
1517 crate::container_resources::resolve_container_resources(
1518 &mut release.clone(),
1519 Some(&smaller_initial),
1520 )
1521 .unwrap();
1522 let mut installed = StackState::new(Platform::Aws);
1523 installed.resources.insert(
1524 "api".into(),
1525 crate::StackResourceState::new_pending(
1526 "Container".into(),
1527 deployment.resources["api"].config.clone(),
1528 None,
1529 vec![],
1530 ),
1531 );
1532 let shrinking: ComputeSettings = serde_json::from_value(serde_json::json!({
1533 "containers": {"api": {"volumeSizeGib": 30}}
1534 }))
1535 .unwrap();
1536 let error =
1537 plan_compute_with_state(&release, Platform::Aws, Some(&shrinking), Some(&installed))
1538 .unwrap_err();
1539 assert_eq!(error.code, "CONTAINER_RESOURCE_SELECTION_INVALID");
1540 assert!(error.to_string().contains("cannot shrink from 40 to 30"));
1541 let imported: ComputeSettings = serde_json::from_value(serde_json::json!({
1542 "containers": {"api": {"volumeSizeGib": "40"}}
1543 }))
1544 .unwrap();
1545 assert_eq!(imported.containers["api"].volume_size_gib, Some(40));
1546 for value in [
1547 serde_json::json!(-1),
1548 serde_json::json!(1.5),
1549 serde_json::json!("4294967296"),
1550 ] {
1551 assert!(
1552 serde_json::from_value::<ComputeSettings>(serde_json::json!({
1553 "containers": {"api": {"volumeSizeGib": value}}
1554 }))
1555 .is_err()
1556 );
1557 }
1558 for (platform, size) in [(Platform::Gcp, 9), (Platform::Azure, 32768)] {
1559 let invalid: ComputeSettings = serde_json::from_value(serde_json::json!({
1560 "containers": {"api": {"volumeSizeGib": size}}
1561 }))
1562 .unwrap();
1563 let error = plan_compute(&release, platform, Some(&invalid)).unwrap_err();
1564 assert_eq!(error.code, "CONTAINER_RESOURCE_SELECTION_INVALID");
1565 assert!(error.to_string().contains("must be between"));
1566 }
1567 for size in [0, 65537] {
1568 let invalid: ComputeSettings = serde_json::from_value(serde_json::json!({
1569 "containers": {"api": {"volumeSizeGib": size}}
1570 }))
1571 .unwrap();
1572 assert_eq!(
1573 crate::container_resources::resolve_container_resources(
1574 &mut release.clone(),
1575 Some(&invalid)
1576 )
1577 .unwrap_err()
1578 .code,
1579 "CONTAINER_RESOURCE_SELECTION_INVALID"
1580 );
1581 }
1582 assert_eq!(
1583 crate::container_resources::resolve_container_resources(
1584 &mut stack_with_container(),
1585 Some(&settings)
1586 )
1587 .unwrap_err()
1588 .code,
1589 "CONTAINER_RESOURCE_SELECTION_INVALID"
1590 );
1591 }
1592
1593 fn stack_with_volume(size: &str) -> Stack {
1594 let mut stack = stack_with_container();
1595 stack
1596 .resources
1597 .get_mut("api")
1598 .unwrap()
1599 .config
1600 .downcast_mut::<Container>()
1601 .unwrap()
1602 .persistent_storage = Some(crate::PersistentStorage {
1603 size: size.into(),
1604 mount_path: "/data".into(),
1605 backups: crate::VolumeBackups::default(),
1606 encryption_key: None,
1607 });
1608 stack
1609 }
1610
1611 fn installed_with_volume(platform: Platform, size: &str) -> StackState {
1612 let mut installed = StackState::new(platform);
1613 installed.resources.insert(
1614 "api".into(),
1615 crate::StackResourceState::new_pending(
1616 "Container".into(),
1617 stack_with_volume(size).resources["api"].config.clone(),
1618 None,
1619 vec![],
1620 ),
1621 );
1622 installed
1623 }
1624
1625 fn api_volume(plan: &ComputePlan) -> (Option<u32>, Option<u32>) {
1626 let api = plan
1627 .containers
1628 .iter()
1629 .find(|container| container.container_id == "api")
1630 .expect("api is planned");
1631 (api.volume_size_gib, api.minimum_volume_size_gib)
1632 }
1633
1634 #[test]
1638 fn a_lower_release_default_keeps_installed_volumes_without_a_selection() {
1639 for platform in [Platform::Aws, Platform::Gcp, Platform::Azure] {
1640 let installed = installed_with_volume(platform, "30Gi");
1641 let plan = plan_compute_with_state(
1642 &stack_with_volume("20Gi"),
1643 platform,
1644 None,
1645 Some(&installed),
1646 )
1647 .expect("a lower release default keeps the installed volume");
1648 assert_eq!(api_volume(&plan), (Some(30), Some(30)), "{platform}");
1649
1650 let plan = plan_compute_with_state(
1651 &stack_with_volume("40Gi"),
1652 platform,
1653 None,
1654 Some(&installed),
1655 )
1656 .expect("a larger release default grows the volume");
1657 assert_eq!(api_volume(&plan), (Some(40), Some(30)), "{platform}");
1658
1659 let shrinking: ComputeSettings = serde_json::from_value(serde_json::json!({
1661 "containers": {"api": {"volumeSizeGib": 25}}
1662 }))
1663 .unwrap();
1664 let error = plan_compute_with_state(
1665 &stack_with_volume("20Gi"),
1666 platform,
1667 Some(&shrinking),
1668 Some(&installed),
1669 )
1670 .unwrap_err();
1671 assert!(
1672 error.to_string().contains("cannot shrink from 30 to 25"),
1673 "{error}"
1674 );
1675 }
1676 }
1677
1678 #[test]
1682 fn setup_source_volume_sizes_below_installed_keep_the_installed_size() {
1683 let mut installed = installed_with_volume(Platform::Aws, "30Gi");
1684 let mut worker = stack_with_volume("10Gi").resources["api"].config.clone();
1685 worker
1686 .downcast_mut::<Container>()
1687 .unwrap()
1688 .persistent_storage = None;
1689 installed.resources.insert(
1690 "worker".into(),
1691 crate::StackResourceState::new_pending("Container".into(), worker, None, vec![]),
1692 );
1693 let mut settings: ComputeSettings = serde_json::from_value(serde_json::json!({
1694 "containers": {
1695 "api": {"volumeSizeGib": 25, "cpu": 2},
1696 "worker": {"volumeSizeGib": 5},
1697 "new": {"volumeSizeGib": 8}
1698 }
1699 }))
1700 .unwrap();
1701
1702 let kept = crate::container_resources::keep_volume_sizes_over_setup_source(
1703 &mut settings,
1704 Some(&installed),
1705 None,
1706 );
1707
1708 assert_eq!(
1709 kept,
1710 vec![crate::container_resources::KeptVolumeSize {
1711 container_id: "api".into(),
1712 requested_gib: 25,
1713 kept_gib: 30,
1714 }]
1715 );
1716 assert_eq!(settings.containers["api"].volume_size_gib, Some(30));
1717 assert_eq!(
1718 settings.containers["api"].cpu,
1719 Some(serde_json::Number::from(2))
1720 );
1721 assert_eq!(settings.containers["worker"].volume_size_gib, Some(5));
1722 assert_eq!(settings.containers["new"].volume_size_gib, Some(8));
1723 let plan = plan_compute_with_state(
1724 &stack_with_volume("20Gi"),
1725 Platform::Aws,
1726 Some(&ComputeSettings {
1727 containers: [("api".to_string(), settings.containers["api"].clone())].into(),
1728 ..Default::default()
1729 }),
1730 Some(&installed_with_volume(Platform::Aws, "30Gi")),
1731 )
1732 .expect("the kept size plans");
1733 assert_eq!(api_volume(&plan), (Some(30), Some(30)));
1734
1735 let mut growing: ComputeSettings = serde_json::from_value(serde_json::json!({
1736 "containers": {"api": {"volumeSizeGib": 35}}
1737 }))
1738 .unwrap();
1739 assert!(
1740 crate::container_resources::keep_volume_sizes_over_setup_source(
1741 &mut growing,
1742 Some(&installed),
1743 None,
1744 )
1745 .is_empty()
1746 );
1747 assert_eq!(growing.containers["api"].volume_size_gib, Some(35));
1748
1749 let chosen: ComputeSettings = serde_json::from_value(serde_json::json!({
1751 "containers": {"api": {"volumeSizeGib": 40}}
1752 }))
1753 .unwrap();
1754 let mut stale: ComputeSettings = serde_json::from_value(serde_json::json!({
1755 "containers": {"api": {"volumeSizeGib": 25}}
1756 }))
1757 .unwrap();
1758 let kept = crate::container_resources::keep_volume_sizes_over_setup_source(
1759 &mut stale,
1760 Some(&installed),
1761 Some(&chosen),
1762 );
1763 assert_eq!(kept[0].kept_gib, 40);
1764 assert_eq!(stale.containers["api"].volume_size_gib, Some(40));
1765
1766 let mut correction: ComputeSettings = serde_json::from_value(serde_json::json!({
1769 "containers": {"api": {"volumeSizeGib": 20}}
1770 }))
1771 .unwrap();
1772 for nothing_installed in [None, Some(&StackState::new(Platform::Aws))] {
1773 assert!(
1774 crate::container_resources::keep_volume_sizes_over_setup_source(
1775 &mut correction,
1776 nothing_installed,
1777 Some(&chosen),
1778 )
1779 .is_empty()
1780 );
1781 assert_eq!(correction.containers["api"].volume_size_gib, Some(20));
1782 }
1783 }
1784
1785 #[test]
1786 fn installed_storage_cannot_be_removed_or_remounted_on_any_provider() {
1787 for platform in [
1788 Platform::Aws,
1789 Platform::Gcp,
1790 Platform::Azure,
1791 Platform::Kubernetes,
1792 ] {
1793 let mut release = stack_with_container();
1794 release
1795 .resources
1796 .get_mut("api")
1797 .unwrap()
1798 .config
1799 .downcast_mut::<Container>()
1800 .unwrap()
1801 .persistent_storage = Some(crate::PersistentStorage {
1802 size: "20Gi".into(),
1803 mount_path: "/data".into(),
1804 backups: crate::VolumeBackups::default(),
1805 encryption_key: None,
1806 });
1807 let mut installed = StackState::new(platform);
1808 installed.resources.insert(
1809 "api".into(),
1810 crate::StackResourceState::new_pending(
1811 "Container".into(),
1812 release.resources["api"].config.clone(),
1813 None,
1814 vec![],
1815 ),
1816 );
1817 for remove in [false, true] {
1818 let mut desired = release.clone();
1819 let storage = &mut desired
1820 .resources
1821 .get_mut("api")
1822 .unwrap()
1823 .config
1824 .downcast_mut::<Container>()
1825 .unwrap()
1826 .persistent_storage;
1827 if remove {
1828 *storage = None;
1829 } else {
1830 storage.as_mut().unwrap().mount_path = "/other".into();
1831 }
1832 let error = plan_compute_with_state(&desired, platform, None, Some(&installed))
1833 .unwrap_err();
1834 assert_eq!(error.code, "CONTAINER_RESOURCE_SELECTION_INVALID");
1835 assert!(
1836 error.to_string().contains(if remove {
1837 "removing installed"
1838 } else {
1839 "mount path"
1840 }),
1841 "{error}"
1842 );
1843 }
1844 }
1845 }
1846
1847 #[test]
1848 fn release_volume_defaults_obey_provider_bounds_without_a_selection() {
1849 for (platform, size) in [(Platform::Gcp, "5Gi"), (Platform::Azure, "40000Gi")] {
1850 let mut stack = stack_with_container();
1851 stack
1852 .resources
1853 .get_mut("api")
1854 .unwrap()
1855 .config
1856 .downcast_mut::<Container>()
1857 .unwrap()
1858 .persistent_storage = Some(crate::PersistentStorage {
1859 size: size.into(),
1860 mount_path: "/data".into(),
1861 backups: crate::VolumeBackups::default(),
1862 encryption_key: None,
1863 });
1864 let error = plan_compute(&stack, platform, None).unwrap_err();
1865 assert_eq!(error.code, "CONTAINER_RESOURCE_SELECTION_INVALID");
1866 assert!(error.to_string().contains("must be between"));
1867 }
1868 }
1869
1870 #[test]
1871 fn volume_capacity_parsing_rejects_overflow_and_preserves_legacy_units() {
1872 use crate::container_resources::parse_volume_size_gib;
1873 for (value, size) in [
1874 ("20Gi", 20),
1875 ("20GiB", 20),
1876 ("20GB", 20),
1877 ("1Ti", 1024),
1878 ("1TB", 1000),
1879 ("1.5Ti", 1536),
1880 ("1536Mi", 2),
1881 ("0.5Gi", 1),
1882 ("1073741824", 1),
1883 ] {
1884 assert_eq!(parse_volume_size_gib(value).unwrap(), size);
1885 }
1886 for value in ["0Gi", "-1Gi", "NaNGi", "4294967295Ti", "65537Gi"] {
1887 assert!(parse_volume_size_gib(value).is_err(), "{value}");
1888 }
1889 }
1890
1891 fn stack_with_container() -> Stack {
1892 let container = Container::new("api".to_string())
1893 .code(ContainerCode::Image {
1894 image: "api:latest".to_string(),
1895 })
1896 .cpu(ResourceSpec {
1897 min: "1".to_string(),
1898 desired: "2".to_string(),
1899 })
1900 .memory(ResourceSpec {
1901 min: "2Gi".to_string(),
1902 desired: "4Gi".to_string(),
1903 })
1904 .permissions("api".to_string())
1905 .build();
1906 Stack {
1907 id: "test".to_string(),
1908 resources: [(
1909 "api".to_string(),
1910 ResourceEntry {
1911 config: Resource::new(container),
1912 lifecycle: ResourceLifecycle::Live,
1913 dependencies: Vec::new(),
1914 display_name: None,
1915 description: None,
1916 remote_access: false,
1917 enabled_when: None,
1918 },
1919 )]
1920 .into_iter()
1921 .collect(),
1922 permissions: crate::permissions::PermissionsConfig::default(),
1923 supported_platforms: None,
1924 inputs: vec![],
1925 dynamic_container_repositories: Vec::new(),
1926 dynamic_container_image_resources: Vec::new(),
1927 operations: None,
1928 }
1929 }
1930
1931 #[test]
1932 fn serverless_workers_and_storage_do_not_allocate_machine_pools() {
1933 let mut stack = stack_with_container();
1934 stack.resources.clear();
1935 for (id, resource) in [
1936 (
1937 "api",
1938 Resource::new(
1939 Worker::new("api".to_string())
1940 .code(WorkerCode::Image {
1941 image: "example/worker:v1".to_string(),
1942 })
1943 .permissions("execution".to_string())
1944 .build(),
1945 ),
1946 ),
1947 (
1948 "data",
1949 Resource::new(Storage::new("data".to_string()).build()),
1950 ),
1951 ] {
1952 stack.resources.insert(
1953 id.to_string(),
1954 ResourceEntry {
1955 config: resource,
1956 lifecycle: ResourceLifecycle::Frozen,
1957 dependencies: Vec::new(),
1958 display_name: None,
1959 description: None,
1960 remote_access: false,
1961 enabled_when: None,
1962 },
1963 );
1964 }
1965
1966 for platform in [Platform::Aws, Platform::Gcp, Platform::Azure] {
1967 let plan = plan_compute(&stack, platform, None).expect("plan should build");
1968 assert!(plan.pools.is_empty(), "serverless plan allocated {plan:?}");
1969 }
1970 }
1971
1972 #[test]
1973 fn empty_stack_does_not_allocate_machine_pools() {
1974 let mut stack = stack_with_container();
1975 stack.resources.clear();
1976 let plan = plan_compute(&stack, Platform::Aws, None).expect("plan should build");
1977 assert!(plan.pools.is_empty());
1978 }
1979
1980 #[test]
1981 fn explicit_cluster_without_workloads_only_plans_declared_pools() {
1982 let mut stack = stack_with_container();
1983 stack.resources.clear();
1984 let cluster = ComputeCluster::new("compute".to_string())
1985 .capacity_group(CapacityGroup {
1986 group_id: "batch".to_string(),
1987 instance_type: Some("t4g.medium".to_string()),
1988 profile: None,
1989 min_size: 1,
1990 max_size: 1,
1991 scale_policy: None,
1992 nested_virtualization: None,
1993 data_disk_gib_range: None,
1994 })
1995 .build();
1996 stack.resources.insert(
1997 "compute".to_string(),
1998 ResourceEntry {
1999 config: Resource::new(cluster),
2000 lifecycle: ResourceLifecycle::Frozen,
2001 dependencies: Vec::new(),
2002 display_name: None,
2003 description: None,
2004 remote_access: false,
2005 enabled_when: None,
2006 },
2007 );
2008
2009 let plan = plan_compute(&stack, Platform::Aws, None).expect("plan should build");
2010 assert_eq!(plan.pools.len(), 1);
2011 assert_eq!(plan.pools[0].pool_id, "batch");
2012 assert!(plan.pools[0].errors.is_empty());
2013 }
2014
2015 #[test]
2016 fn cloud_plan_recommends_provider_machine_without_mutating_selection() {
2017 let stack = stack_with_container();
2018
2019 let plan = plan_compute(&stack, Platform::Aws, None).expect("plan should build");
2020
2021 let pool = plan.pools.first().expect("general pool should exist");
2022 assert_eq!(pool.pool_id, "general");
2023 assert_eq!(pool.workloads, vec!["api"]);
2024 assert!(pool.selected.machine().is_some());
2025 assert!(pool.machines.iter().any(|machine| machine.recommended));
2026 }
2027
2028 #[test]
2029 fn cloud_plan_only_offers_machines_matching_the_default_image_target() {
2030 let stack = stack_with_container();
2031
2032 for platform in [Platform::Aws, Platform::Gcp, Platform::Azure] {
2033 let plan = plan_compute(&stack, platform, None).expect("plan should build");
2034 let pool = plan.pools.first().expect("general pool should exist");
2035 let expected = instance_catalog::default_architecture(platform)
2036 .expect("managed cloud should have a default architecture");
2037
2038 assert!(pool.machines.iter().any(|machine| machine.available));
2039 for machine in &pool.machines {
2040 let spec = instance_catalog::find_instance_type(platform, &machine.machine)
2041 .expect("every option is a catalog machine");
2042 if machine.available {
2043 assert_eq!(spec.architecture, expected, "{}", machine.machine);
2044 }
2045 assert_eq!(
2046 machine
2047 .unavailable_reasons
2048 .contains(&MachineUnavailableReason::Architecture),
2049 spec.architecture != expected,
2050 "{}",
2051 machine.machine
2052 );
2053 }
2054 }
2055 }
2056
2057 #[test]
2058 fn selected_machine_is_preserved_as_static_deployment_choice() {
2059 let stack = stack_with_container();
2060 let settings = ComputeSettings {
2061 containers: Default::default(),
2062 pools: [(
2063 "general".to_string(),
2064 ComputePoolSelection::Fixed {
2065 machines: 1,
2066 machine: Some("m7g.xlarge".to_string()),
2067 failure_domains: None,
2068 data_disk_gib: None,
2069 },
2070 )]
2071 .into_iter()
2072 .collect(),
2073 };
2074
2075 let plan = plan_compute(&stack, Platform::Aws, Some(&settings)).expect("plan should build");
2076
2077 let pool = plan.pools.first().expect("general pool should exist");
2078 assert_eq!(pool.selected.machine(), Some("m7g.xlarge"));
2079 assert!(pool.errors.is_empty());
2080 }
2081
2082 #[test]
2083 fn configurable_cloud_disk_satisfies_storage_above_fixed_local_catalog() {
2084 let mut stack = stack_with_container();
2085 let container = stack
2086 .resources
2087 .get_mut("api")
2088 .and_then(|entry| entry.config.downcast_mut::<Container>())
2089 .expect("test stack should contain a container");
2090 container.ephemeral_storage = Some("8000Gi".to_string());
2091 let settings = ComputeSettings {
2092 containers: Default::default(),
2093 pools: [(
2094 "storage".to_string(),
2095 ComputePoolSelection::Fixed {
2096 machines: 1,
2097 machine: Some("m8i.2xlarge".to_string()),
2098 failure_domains: None,
2099 data_disk_gib: None,
2100 },
2101 )]
2102 .into_iter()
2103 .collect(),
2104 };
2105
2106 let plan = plan_compute(&stack, Platform::Aws, Some(&settings)).expect("plan should build");
2107 let pool = plan.pools.first().expect("general pool should exist");
2108 assert!(pool.errors.is_empty());
2109 let selected = pool
2110 .machines
2111 .iter()
2112 .find(|option| option.machine == "m8i.2xlarge")
2113 .expect("selected machine should remain an option");
2114 assert_eq!(
2115 selected.profile.ephemeral_storage_bytes,
2116 8_000 * 1024 * 1024 * 1024
2117 );
2118 }
2119
2120 #[test]
2121 fn selected_machine_defines_architecture_when_workloads_do_not() {
2122 let stack = stack_with_container();
2123 let settings = ComputeSettings {
2124 containers: Default::default(),
2125 pools: [(
2126 "general".to_string(),
2127 ComputePoolSelection::Fixed {
2128 machines: 1,
2129 machine: Some("m7i.xlarge".to_string()),
2130 failure_domains: None,
2131 data_disk_gib: None,
2132 },
2133 )]
2134 .into_iter()
2135 .collect(),
2136 };
2137
2138 let plan = plan_compute(&stack, Platform::Aws, Some(&settings)).expect("plan should build");
2139
2140 let pool = plan.pools.first().expect("general pool should exist");
2141 assert!(pool.errors.is_empty());
2142 assert!(pool.machines.iter().any(|machine| machine.available));
2143 assert!(pool
2144 .machines
2145 .iter()
2146 .filter(|machine| machine.available)
2147 .all(|machine| machine.profile.architecture == Some(Architecture::X86_64)));
2148 }
2149
2150 #[test]
2151 fn graviton4_compute_machine_can_be_selected() {
2152 let stack = stack_with_container();
2153 let settings = ComputeSettings {
2154 containers: Default::default(),
2155 pools: [(
2156 "general".to_string(),
2157 ComputePoolSelection::Fixed {
2158 machines: 1,
2159 machine: Some("c8g.2xlarge".to_string()),
2160 failure_domains: None,
2161 data_disk_gib: None,
2162 },
2163 )]
2164 .into_iter()
2165 .collect(),
2166 };
2167
2168 let plan = plan_compute(&stack, Platform::Aws, Some(&settings)).expect("plan should build");
2169
2170 let pool = plan.pools.first().expect("general pool should exist");
2171 assert!(pool.errors.is_empty(), "{:?}", pool.errors);
2172 let selected = pool
2173 .machines
2174 .iter()
2175 .find(|option| option.machine == "c8g.2xlarge")
2176 .expect("selected machine should be offered");
2177 assert!(selected.recommended);
2178 assert_eq!(selected.profile.architecture, Some(Architecture::Arm64));
2179 }
2180
2181 #[test]
2182 fn explicit_capacity_group_requirements_are_merged_with_workloads() {
2183 let mut stack = stack_with_container();
2184 let cluster = ComputeCluster::new("compute".to_string())
2185 .capacity_group(CapacityGroup {
2186 group_id: "general".to_string(),
2187 instance_type: None,
2188 profile: Some(MachineProfile {
2189 cpu: "4".to_string(),
2190 memory_bytes: 16 * 1024 * 1024 * 1024,
2191 ephemeral_storage_bytes: 20 * 1024 * 1024 * 1024,
2192 architecture: Some(Architecture::X86_64),
2193 gpu: None,
2194 data_disk: None,
2195 }),
2196 min_size: 2,
2197 max_size: 5,
2198 scale_policy: None,
2199 nested_virtualization: Some(true),
2200 data_disk_gib_range: None,
2201 })
2202 .build();
2203 stack.resources.insert(
2204 "compute".to_string(),
2205 ResourceEntry {
2206 config: Resource::new(cluster),
2207 lifecycle: ResourceLifecycle::Frozen,
2208 dependencies: Vec::new(),
2209 display_name: None,
2210 description: None,
2211 remote_access: false,
2212 enabled_when: None,
2213 },
2214 );
2215
2216 let plan = plan_compute(&stack, Platform::Aws, None).expect("plan should build");
2217
2218 let pool = plan.pools.first().expect("general pool should exist");
2219 let machine = pool
2220 .selected
2221 .machine()
2222 .expect("AWS selection should include a machine");
2223 let spec = instance_catalog::find_instance_type(Platform::Aws, machine)
2224 .expect("selected machine should exist in the catalog");
2225 assert!(spec.is_nested_virt_capable());
2226 assert_eq!(pool.selected.min_size(), 2);
2227 assert_eq!(pool.selected.max_size(), 5);
2228 assert!(pool.errors.is_empty());
2229 }
2230
2231 #[test]
2237 fn available_machines_fit_one_replica_after_the_host_reserve() {
2238 let mut stack = stack_with_container();
2239 stack
2240 .resources
2241 .get_mut("api")
2242 .unwrap()
2243 .config
2244 .downcast_mut::<Container>()
2245 .unwrap()
2246 .cpu = ResourceSpec {
2247 min: "4".into(),
2248 desired: "4".into(),
2249 };
2250 let plan = plan_compute(&stack, Platform::Aws, None).expect("plan should build");
2251 let available: Vec<_> = plan.pools[0]
2252 .machines
2253 .iter()
2254 .filter(|option| option.available)
2255 .map(|option| option.machine.clone())
2256 .collect();
2257 assert!(!available.is_empty());
2258 assert!(!available.contains(&"t3.xlarge".to_string()));
2259 for machine in available {
2260 let settings: ComputeSettings = serde_json::from_value(serde_json::json!({
2261 "pools":{"general":{"mode":"fixed","machines":1,"machine":machine}}
2262 }))
2263 .unwrap();
2264 let pinned = plan_compute(&stack, Platform::Aws, Some(&settings)).unwrap();
2265 assert!(
2266 pinned.pools[0].errors.is_empty(),
2267 "{machine} is listed as available: {:?}",
2268 pinned.pools[0].errors
2269 );
2270 }
2271 }
2272
2273 #[test]
2276 fn a_pool_without_workloads_keeps_its_own_machine_available() {
2277 let spec = instance_catalog::find_instance_type(Platform::Aws, "m7g.large").unwrap();
2278 let mut stack = stack_with_container();
2279 stack.resources.remove("api");
2280 stack.resources.insert(
2281 "compute".to_string(),
2282 ResourceEntry {
2283 config: Resource::new(
2284 ComputeCluster::new("compute".to_string())
2285 .capacity_group(CapacityGroup {
2286 group_id: "general".to_string(),
2287 instance_type: Some("m7g.large".to_string()),
2288 profile: Some(spec.to_machine_profile()),
2289 min_size: 1,
2290 max_size: 1,
2291 scale_policy: None,
2292 nested_virtualization: None,
2293 data_disk_gib_range: None,
2294 })
2295 .build(),
2296 ),
2297 lifecycle: ResourceLifecycle::Frozen,
2298 dependencies: Vec::new(),
2299 display_name: None,
2300 description: None,
2301 remote_access: false,
2302 enabled_when: None,
2303 },
2304 );
2305 let plan = plan_compute(&stack, Platform::Aws, None).expect("plan should build");
2306 let option = plan.pools[0]
2307 .machines
2308 .iter()
2309 .find(|option| option.machine == "m7g.large")
2310 .expect("m7g.large should be listed");
2311 assert!(option.available, "{:?}", option.unavailable_reasons);
2312 }
2313
2314 #[test]
2315 fn nested_pool_without_architecture_plans_an_aws_machine() {
2316 let mut stack = stack_with_container();
2317 let cluster = ComputeCluster::new("compute".to_string())
2318 .capacity_group(CapacityGroup {
2319 group_id: "general".to_string(),
2320 instance_type: None,
2321 profile: Some(MachineProfile {
2322 cpu: "4".to_string(),
2323 memory_bytes: 16 * 1024 * 1024 * 1024,
2324 ephemeral_storage_bytes: 20 * 1024 * 1024 * 1024,
2325 architecture: None,
2326 gpu: None,
2327 data_disk: None,
2328 }),
2329 min_size: 1,
2330 max_size: 1,
2331 scale_policy: None,
2332 nested_virtualization: Some(true),
2333 data_disk_gib_range: None,
2334 })
2335 .build();
2336 stack.resources.insert(
2337 "compute".to_string(),
2338 ResourceEntry {
2339 config: Resource::new(cluster),
2340 lifecycle: ResourceLifecycle::Frozen,
2341 dependencies: Vec::new(),
2342 display_name: None,
2343 description: None,
2344 remote_access: false,
2345 enabled_when: None,
2346 },
2347 );
2348
2349 let plan = plan_compute(&stack, Platform::Aws, None).expect("plan should build");
2350
2351 let pool = plan.pools.first().expect("general pool should exist");
2352 assert!(pool.errors.is_empty(), "{:?}", pool.errors);
2353 let machine = pool
2354 .selected
2355 .machine()
2356 .expect("AWS selection should include a machine");
2357 let spec = instance_catalog::find_instance_type(Platform::Aws, machine)
2358 .expect("selected machine should exist in the catalog");
2359 assert!(spec.is_nested_virt_capable(), "{machine} is not nested-capable");
2360 assert_eq!(spec.architecture, Architecture::X86_64);
2361
2362 let catalog = instance_catalog::catalog_for_platform(Platform::Aws);
2364 assert_eq!(
2365 pool.machines
2366 .iter()
2367 .map(|option| option.machine.as_str())
2368 .collect::<Vec<_>>(),
2369 catalog.iter().map(|spec| spec.name).collect::<Vec<_>>()
2370 );
2371 for (option, spec) in pool.machines.iter().zip(&catalog) {
2373 let fits = spec.is_nested_virt_capable()
2374 && spec.architecture == Architecture::X86_64
2375 && spec.vcpu >= 4
2376 && spec.memory_bytes >= 16 * GIB
2377 && (spec.has_configurable_ephemeral_storage()
2378 || spec.ephemeral_storage_bytes >= 20 * GIB);
2379 assert_eq!(option.available, fits, "{}", option.machine);
2380 assert_eq!(
2381 option.available,
2382 option.unavailable_reasons.is_empty(),
2383 "{}",
2384 option.machine
2385 );
2386 }
2387
2388 let option = |name: &str| {
2389 pool.machines
2390 .iter()
2391 .find(|option| option.machine == name)
2392 .unwrap_or_else(|| panic!("{name} should be listed"))
2393 };
2394 let reasons = |name: &str| option(name).unavailable_reasons.clone();
2395 assert_eq!(reasons("m8i.xlarge"), vec![]);
2396 assert_eq!(
2397 reasons("m8i.large"),
2398 vec![
2399 MachineUnavailableReason::Cpu,
2400 MachineUnavailableReason::Memory
2401 ]
2402 );
2403 assert_eq!(
2404 reasons("c8i.xlarge"),
2405 vec![MachineUnavailableReason::Memory]
2406 );
2407 assert_eq!(
2408 reasons("m7g.xlarge"),
2409 vec![
2410 MachineUnavailableReason::Architecture,
2411 MachineUnavailableReason::NestedVirtualization
2412 ]
2413 );
2414 assert_eq!(
2415 reasons("t3.xlarge"),
2416 vec![MachineUnavailableReason::NestedVirtualization]
2417 );
2418 assert_eq!(
2419 reasons("t4g.micro"),
2420 vec![
2421 MachineUnavailableReason::Architecture,
2422 MachineUnavailableReason::NestedVirtualization,
2423 MachineUnavailableReason::Cpu,
2424 MachineUnavailableReason::Memory
2425 ]
2426 );
2427
2428 let large = option("m8i.24xlarge");
2429 assert!(large.available);
2430 assert_eq!(large.series, "m8i");
2431 assert_eq!(large.category, InstanceFamily::GeneralPurpose);
2432 assert_eq!(large.label, "General purpose · Intel · 8th gen");
2433 assert!(large.nested_virtualization);
2434 assert_eq!(large.local_storage, None);
2435 let local = option("i7i.xlarge");
2436 assert!(local.available);
2437 assert_eq!(local.category, InstanceFamily::StorageOptimized);
2438 assert_eq!(local.local_storage, Some(893_592 * 1024 * 1024));
2440
2441 let wire = serde_json::to_value(large).expect("option should serialize");
2442 assert_eq!(wire["category"], "generalPurpose");
2443 assert_eq!(wire["nestedVirtualization"], true);
2444 assert_eq!(wire["unavailableReasons"], serde_json::json!([]));
2445 assert!(wire.get("localStorage").is_none());
2446 let wire = serde_json::to_value(option("m7g.xlarge")).expect("option should serialize");
2447 assert_eq!(wire["available"], false);
2448 assert_eq!(
2449 wire["unavailableReasons"],
2450 serde_json::json!(["architecture", "nestedVirtualization"])
2451 );
2452 assert_eq!(
2453 serde_json::to_value(local).expect("option should serialize")["localStorage"],
2454 893_592u64 * 1024 * 1024
2455 );
2456 }
2457
2458 const GIB: u64 = 1024 * 1024 * 1024;
2459
2460 fn stack_with_data_disk_pool(
2463 data_disk_gib: Option<u64>,
2464 range: Option<ComputeChoiceRange>,
2465 ) -> Stack {
2466 let mut stack = stack_with_container();
2467 let cluster = ComputeCluster::new("compute".to_string())
2468 .capacity_group(CapacityGroup {
2469 group_id: "general".to_string(),
2470 instance_type: None,
2471 profile: Some(MachineProfile {
2472 cpu: "2".to_string(),
2473 memory_bytes: 8 * GIB,
2474 ephemeral_storage_bytes: 20 * GIB,
2475 architecture: None,
2476 gpu: None,
2477 data_disk: data_disk_gib.map(|gib| crate::DataDiskRequirement {
2478 size_bytes: gib * GIB,
2479 }),
2480 }),
2481 min_size: 1,
2482 max_size: 1,
2483 scale_policy: None,
2484 nested_virtualization: None,
2485 data_disk_gib_range: range,
2486 })
2487 .build();
2488 stack.resources.insert(
2489 "compute".to_string(),
2490 ResourceEntry {
2491 config: Resource::new(cluster),
2492 lifecycle: ResourceLifecycle::Frozen,
2493 dependencies: Vec::new(),
2494 display_name: None,
2495 description: None,
2496 remote_access: false,
2497 enabled_when: None,
2498 },
2499 );
2500 stack
2501 }
2502
2503 fn data_disk_selection(data_disk_gib: Option<u32>) -> ComputeSettings {
2504 ComputeSettings {
2505 pools: [(
2506 "general".to_string(),
2507 ComputePoolSelection::Fixed {
2508 machines: 1,
2509 machine: None,
2510 failure_domains: None,
2511 data_disk_gib,
2512 },
2513 )]
2514 .into_iter()
2515 .collect(),
2516 containers: HashMap::new(),
2517 }
2518 }
2519
2520 fn general_pool(plan: &ComputePlan) -> &ComputePoolPlan {
2521 plan.pools
2522 .iter()
2523 .find(|pool| pool.pool_id == "general")
2524 .expect("general pool should be planned")
2525 }
2526
2527 #[test]
2528 fn a_deployment_chooses_its_data_disk_within_the_declared_range() {
2529 let range = ComputeChoiceRange {
2530 min: 0,
2531 max: 4096,
2532 default: 200,
2533 };
2534 let stack = stack_with_data_disk_pool(Some(200), Some(range));
2535 for platform in [Platform::Aws, Platform::Machines] {
2536 for (selected, expected_gib) in [(None, 200), (Some(50), 50), (Some(0), 0)] {
2537 let settings = data_disk_selection(selected);
2538 let plan = plan_compute(&stack, platform, Some(&settings)).expect("plan");
2539 let pool = general_pool(&plan);
2540 assert!(
2541 pool.errors.is_empty(),
2542 "{platform} {selected:?}: {:?}",
2543 pool.errors
2544 );
2545 assert_eq!(
2546 pool.data_disk,
2547 Some(ComputePoolDataDiskPlan {
2548 min_gib: 0,
2549 max_gib: 4096,
2550 default_gib: 200,
2551 selected_gib: expected_gib,
2552 }),
2553 "{platform} {selected:?}"
2554 );
2555 assert_eq!(pool.selected.data_disk_gib(), selected, "{platform}");
2557 }
2558
2559 let settings = data_disk_selection(Some(5000));
2560 let plan = plan_compute(&stack, platform, Some(&settings)).expect("plan");
2561 assert_eq!(
2562 general_pool(&plan).errors,
2563 vec!["pool 'general' data disk must be between 0 and 4096 GiB; dataDiskGib 5000 is outside that range".to_string()],
2564 "{platform}"
2565 );
2566 }
2567 }
2568
2569 #[test]
2570 fn a_fixed_or_absent_data_disk_refuses_another_size() {
2571 let fixed = stack_with_data_disk_pool(Some(100), None);
2572 let plan = plan_compute(&fixed, Platform::Aws, None).expect("plan");
2573 assert_eq!(
2574 general_pool(&plan).data_disk,
2575 Some(ComputePoolDataDiskPlan {
2576 min_gib: 100,
2577 max_gib: 100,
2578 default_gib: 100,
2579 selected_gib: 100,
2580 })
2581 );
2582 let plan = plan_compute(&fixed, Platform::Aws, Some(&data_disk_selection(Some(200))))
2583 .expect("plan");
2584 assert_eq!(
2585 general_pool(&plan).errors,
2586 vec![
2587 "pool 'general' has a fixed 100 GiB data disk; dataDiskGib 200 cannot be chosen"
2588 .to_string()
2589 ]
2590 );
2591
2592 let absent = stack_with_data_disk_pool(None, None);
2593 let plan = plan_compute(&absent, Platform::Aws, Some(&data_disk_selection(Some(0))))
2594 .expect("plan");
2595 assert!(general_pool(&plan).errors.is_empty());
2596 assert_eq!(general_pool(&plan).data_disk, None);
2597 let plan = plan_compute(&absent, Platform::Aws, Some(&data_disk_selection(Some(50))))
2598 .expect("plan");
2599 assert_eq!(
2600 general_pool(&plan).errors,
2601 vec![
2602 "pool 'general' declares no data disk, so dataDiskGib 50 cannot be chosen"
2603 .to_string()
2604 ]
2605 );
2606 }
2607
2608 #[test]
2609 fn materialized_machine_profile_is_not_counted_as_workload_demand() {
2610 let mut stack = stack_with_container();
2611 let selected = instance_catalog::find_instance_type(Platform::Aws, "m7g.xlarge")
2612 .expect("machine should exist in catalog");
2613 let cluster = ComputeCluster::new("compute".to_string())
2614 .capacity_group(CapacityGroup {
2615 group_id: "general".to_string(),
2616 instance_type: Some(selected.name.to_string()),
2617 profile: Some(selected.to_machine_profile()),
2618 min_size: 1,
2619 max_size: 1,
2620 scale_policy: None,
2621 nested_virtualization: None,
2622 data_disk_gib_range: None,
2623 })
2624 .build();
2625 stack.resources.insert(
2626 "compute".to_string(),
2627 ResourceEntry {
2628 config: Resource::new(cluster),
2629 lifecycle: ResourceLifecycle::Frozen,
2630 dependencies: Vec::new(),
2631 display_name: None,
2632 description: None,
2633 remote_access: false,
2634 enabled_when: None,
2635 },
2636 );
2637
2638 let plan = plan_compute(&stack, Platform::Aws, None).expect("plan should build");
2639 let pool = plan.pools.first().expect("general pool should exist");
2640
2641 assert_eq!(pool.requirements.cpu, "2");
2642 assert_eq!(pool.requirements.memory_bytes, 4 * 1024 * 1024 * 1024);
2643 assert_eq!(pool.recommended.machine(), Some("m7g.xlarge"));
2644 assert!(pool
2645 .machines
2646 .iter()
2647 .any(|option| option.machine == "m7g.xlarge"));
2648 }
2649
2650 #[test]
2651 fn nested_x86_fixed_range_pool_preserves_bounds_and_rejects_graviton() {
2652 let daemon = Daemon::new("vm-runtime-loader".to_string())
2653 .code(DaemonCode::Image {
2654 image: "example.com/vm-runtime:latest".to_string(),
2655 })
2656 .cluster("vm-runtime".to_string())
2657 .cpu(ResourceSpec {
2658 min: "2".to_string(),
2659 desired: "2".to_string(),
2660 })
2661 .memory(ResourceSpec {
2662 min: "4Gi".to_string(),
2663 desired: "4Gi".to_string(),
2664 })
2665 .permissions("loader".to_string())
2666 .build();
2667 let cluster = ComputeCluster::new("vm-runtime".to_string())
2668 .capacity_group(CapacityGroup {
2669 group_id: "general".to_string(),
2670 instance_type: None,
2671 profile: Some(MachineProfile {
2672 cpu: "4".to_string(),
2673 memory_bytes: 16 * 1024 * 1024 * 1024,
2674 ephemeral_storage_bytes: 20 * 1024 * 1024 * 1024,
2675 architecture: Some(Architecture::X86_64),
2676 gpu: None,
2677 data_disk: None,
2678 }),
2679 min_size: 2,
2680 max_size: 2,
2681 scale_policy: Some(CapacityGroupScalePolicy::Fixed {
2682 machines: ComputeChoiceRange {
2683 min: 1,
2684 max: 5,
2685 default: 2,
2686 },
2687 }),
2688 nested_virtualization: Some(true),
2689 data_disk_gib_range: None,
2690 })
2691 .build();
2692 let stack = Stack {
2693 id: "vm-runtime".to_string(),
2694 resources: [
2695 (
2696 "vm-runtime-loader".to_string(),
2697 ResourceEntry {
2698 config: Resource::new(daemon),
2699 lifecycle: ResourceLifecycle::Live,
2700 dependencies: Vec::new(),
2701 display_name: None,
2702 description: None,
2703 remote_access: false,
2704 enabled_when: None,
2705 },
2706 ),
2707 (
2708 "vm-runtime".to_string(),
2709 ResourceEntry {
2710 config: Resource::new(cluster),
2711 lifecycle: ResourceLifecycle::Frozen,
2712 dependencies: Vec::new(),
2713 display_name: None,
2714 description: None,
2715 remote_access: false,
2716 enabled_when: None,
2717 },
2718 ),
2719 ]
2720 .into_iter()
2721 .collect(),
2722 permissions: crate::permissions::PermissionsConfig::default(),
2723 supported_platforms: None,
2724 inputs: vec![],
2725 dynamic_container_repositories: Vec::new(),
2726 dynamic_container_image_resources: Vec::new(),
2727 operations: None,
2728 };
2729
2730 let plan = plan_compute(&stack, Platform::Aws, None).expect("plan should build");
2731 let pool = plan.pools.first().expect("general pool should exist");
2732 assert_eq!(pool.recommended.machine(), Some("m8i.2xlarge"));
2733 assert_eq!(pool.recommended.min_size(), 2);
2734 assert_eq!(pool.recommended.max_size(), 2);
2735 assert_eq!(
2736 pool.scale,
2737 CapacityGroupScalePolicy::Fixed {
2738 machines: ComputeChoiceRange {
2739 min: 1,
2740 max: 5,
2741 default: 2,
2742 },
2743 }
2744 );
2745 let graviton = pool
2746 .machines
2747 .iter()
2748 .find(|option| option.machine == "m7g.2xlarge")
2749 .expect("every AWS machine is listed");
2750 assert!(!graviton.available);
2751 assert_eq!(
2752 graviton.unavailable_reasons,
2753 vec![
2754 MachineUnavailableReason::Architecture,
2755 MachineUnavailableReason::NestedVirtualization
2756 ]
2757 );
2758
2759 let invalid_settings = ComputeSettings {
2760 containers: Default::default(),
2761 pools: [(
2762 "general".to_string(),
2763 ComputePoolSelection::Fixed {
2764 machines: 2,
2765 machine: Some("m7g.2xlarge".to_string()),
2766 failure_domains: None,
2767 data_disk_gib: None,
2768 },
2769 )]
2770 .into_iter()
2771 .collect(),
2772 };
2773 let invalid_plan = plan_compute(&stack, Platform::Aws, Some(&invalid_settings))
2774 .expect("plan should build");
2775 assert_eq!(
2776 invalid_plan.pools[0].errors,
2777 vec!["aws machine 'm7g.2xlarge' does not satisfy pool 'general' requirements"]
2778 );
2779 }
2780
2781 fn declared_pool_errors(
2782 scale: CapacityGroupScalePolicy,
2783 selection: ComputePoolSelection,
2784 ) -> Vec<String> {
2785 let mut stack = stack_with_container();
2786 let cluster = ComputeCluster::new("compute".to_string())
2787 .capacity_group(CapacityGroup {
2788 group_id: "general".to_string(),
2789 instance_type: None,
2790 profile: None,
2791 min_size: scale.default_min_size(),
2792 max_size: scale.default_max_size(),
2793 scale_policy: Some(scale),
2794 nested_virtualization: None,
2795 data_disk_gib_range: None,
2796 })
2797 .build();
2798 stack.resources.insert(
2799 "compute".to_string(),
2800 ResourceEntry {
2801 config: Resource::new(cluster),
2802 lifecycle: ResourceLifecycle::Frozen,
2803 dependencies: Vec::new(),
2804 display_name: None,
2805 description: None,
2806 remote_access: false,
2807 enabled_when: None,
2808 },
2809 );
2810 let settings = ComputeSettings {
2811 containers: Default::default(),
2812 pools: [("general".to_string(), selection)].into_iter().collect(),
2813 };
2814 plan_compute(&stack, Platform::Aws, Some(&settings))
2815 .expect("plan should build")
2816 .pools
2817 .remove(0)
2818 .errors
2819 }
2820
2821 fn range(min: u32, max: u32, default: u32) -> ComputeChoiceRange {
2822 ComputeChoiceRange { min, max, default }
2823 }
2824
2825 #[test]
2826 fn generated_pool_offers_the_recommendation_as_a_default_not_a_bound() {
2827 let mut stack = stack_with_container();
2828 let api = stack
2829 .resources
2830 .get_mut("api")
2831 .and_then(|entry| entry.config.downcast_mut::<Container>())
2832 .expect("test stack should contain a container");
2833 api.autoscaling = Some(crate::ContainerAutoscaling {
2834 min: 1,
2835 desired: 1,
2836 max: 3,
2837 target_cpu_percent: Some(70.0),
2838 target_memory_percent: None,
2839 target_http_in_flight_per_replica: None,
2840 max_http_p95_latency_ms: None,
2841 });
2842
2843 let plan = plan_compute(&stack, Platform::Aws, None).expect("plan should build");
2844 let pool = &plan.pools[0];
2845 assert_eq!(
2846 pool.scale,
2847 CapacityGroupScalePolicy::Autoscale {
2848 min: range(1, 100, 1),
2849 max: range(1, 100, 3),
2850 }
2851 );
2852 assert_eq!(
2853 (pool.recommended.min_size(), pool.recommended.max_size()),
2854 (1, 3)
2855 );
2856
2857 let plan =
2860 plan_compute(&stack_with_container(), Platform::Aws, None).expect("plan should build");
2861 assert_eq!(
2862 plan.pools[0].scale,
2863 CapacityGroupScalePolicy::Autoscale {
2864 min: range(1, 100, 1),
2865 max: range(1, 100, 1),
2866 }
2867 );
2868 assert!(matches!(
2869 plan.pools[0].recommended,
2870 ComputePoolSelection::Fixed { machines: 1, .. }
2871 ));
2872 assert!(plan.pools[0].errors.is_empty());
2873 }
2874
2875 #[test]
2876 fn generated_pool_rejects_a_fleet_too_small_for_desired_replicas() {
2877 let mut stack = stack_with_container();
2878 let api = stack
2879 .resources
2880 .get_mut("api")
2881 .and_then(|entry| entry.config.downcast_mut::<Container>())
2882 .expect("test stack should contain a container");
2883 api.replicas = Some(3);
2885 let selection = |machines| ComputeSettings {
2886 containers: Default::default(),
2887 pools: [(
2888 "general".to_string(),
2889 ComputePoolSelection::Fixed {
2890 machines,
2891 machine: Some("m7g.xlarge".to_string()),
2892 failure_domains: None,
2893 data_disk_gib: None,
2894 },
2895 )]
2896 .into_iter()
2897 .collect(),
2898 };
2899
2900 let errors = plan_compute(&stack, Platform::Aws, Some(&selection(1)))
2902 .expect("plan should build")
2903 .pools
2904 .remove(0)
2905 .errors;
2906 assert_eq!(errors.len(), 1, "{errors:?}");
2907 assert!(
2908 errors[0].starts_with(
2909 "Pool 'general' is too small for its workloads: 1 x m7g.xlarge has 3.50 vCPU"
2910 ),
2911 "{errors:?}"
2912 );
2913 assert!(errors[0].contains("request 6.00 vCPU"), "{errors:?}");
2914
2915 let errors = plan_compute(&stack, Platform::Aws, Some(&selection(2)))
2916 .expect("plan should build")
2917 .pools
2918 .remove(0)
2919 .errors;
2920 assert!(errors.is_empty(), "{errors:?}");
2921 }
2922
2923 #[test]
2924 fn fixed_and_equal_autoscale_bounds_are_interchangeable_for_declared_pools() {
2925 let fixed = |machines| ComputePoolSelection::Fixed {
2926 machines,
2927 machine: Some("m7g.xlarge".to_string()),
2928 failure_domains: None,
2929 data_disk_gib: None,
2930 };
2931 let autoscale = |min, max| ComputePoolSelection::Autoscale {
2932 min,
2933 max,
2934 machine: Some("m7g.xlarge".to_string()),
2935 failure_domains: None,
2936 data_disk_gib: None,
2937 };
2938 let declared_fixed = CapacityGroupScalePolicy::Fixed {
2939 machines: range(2, 4, 2),
2940 };
2941 let declared_autoscale = CapacityGroupScalePolicy::Autoscale {
2942 min: range(1, 3, 1),
2943 max: range(2, 6, 4),
2944 };
2945
2946 assert!(declared_pool_errors(declared_fixed.clone(), autoscale(3, 3)).is_empty());
2947 assert_eq!(
2948 declared_pool_errors(declared_fixed.clone(), autoscale(2, 4)),
2949 vec!["Pool 'general' must use fixed mode with 2-4 machines; autoscale 2-4 is not allowed"]
2950 );
2951 assert_eq!(
2952 declared_pool_errors(declared_fixed, autoscale(5, 5)),
2953 vec!["Pool 'general' machine count 5 is outside the allowed range 2-4"]
2954 );
2955
2956 assert!(declared_pool_errors(declared_autoscale.clone(), fixed(3)).is_empty());
2958 assert_eq!(
2959 declared_pool_errors(declared_autoscale.clone(), fixed(1)),
2960 vec!["Pool 'general' fixed machine count 1 is outside the allowed range 2-3"]
2961 );
2962 assert_eq!(
2963 declared_pool_errors(
2964 CapacityGroupScalePolicy::Autoscale {
2965 min: range(1, 1, 1),
2966 max: range(3, 5, 3),
2967 },
2968 fixed(1),
2969 ),
2970 vec!["Pool 'general' must use autoscale mode (minimum 1-1, maximum 3-5)"]
2971 );
2972 assert_eq!(
2973 declared_pool_errors(declared_autoscale, autoscale(1, 7)),
2974 vec!["Pool 'general' autoscale maximum 7 is outside the allowed range 2-6"]
2975 );
2976 }
2977
2978 #[test]
2979 fn local_plan_has_no_provider_machine_choices() {
2980 let stack = stack_with_container();
2981
2982 let plan = plan_compute(&stack, Platform::Local, None).expect("plan should build");
2983
2984 let pool = plan.pools.first().expect("general pool should exist");
2985 assert_eq!(pool.selected.machine(), None);
2986 assert!(pool.machines.is_empty());
2987 assert!(pool.errors.is_empty());
2988 }
2989}