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