1use crate::{
8 instance_catalog::{self, Architecture, 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>,
72 #[serde(default, skip_serializing_if = "Vec::is_empty")]
74 pub errors: Vec<String>,
75}
76
77#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
79#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
80#[serde(rename_all = "camelCase")]
81pub struct ComputeMachineOption {
82 pub machine: String,
84 pub profile: MachineProfile,
86 pub recommended: bool,
88}
89
90pub fn default_persistent_failure_domains() -> FailureDomainSelection {
94 FailureDomainSelection {
95 spread: 1,
96 selected_failure_domains: Vec::new(),
97 }
98}
99
100pub fn plan_compute(
102 stack: &Stack,
103 platform: Platform,
104 selected_settings: Option<&crate::ComputeSettings>,
105) -> Result<ComputePlan, ErrorData> {
106 plan_compute_with_state(stack, platform, selected_settings, None)
107}
108
109pub fn plan_compute_with_state(
112 stack: &Stack,
113 platform: Platform,
114 selected_settings: Option<&crate::ComputeSettings>,
115 previous_state: Option<&StackState>,
116) -> Result<ComputePlan, ErrorData> {
117 let mut resolved_stack = stack.clone();
118 crate::container_resources::resolve_container_resources(
119 &mut resolved_stack,
120 selected_settings,
121 )?;
122 if let Some(previous_state) = previous_state {
123 crate::container_resources::retain_installed_volume_sizes(
124 &mut resolved_stack,
125 selected_settings,
126 previous_state,
127 );
128 }
129 let (minimum, maximum) = match platform {
130 Platform::Gcp => (10, 65536),
131 Platform::Azure => (1, 32767),
132 _ => (1, 65536),
133 };
134 for (id, entry) in &resolved_stack.resources {
135 let Some(storage) = entry
136 .config
137 .downcast_ref::<Container>()
138 .and_then(|container| container.persistent_storage.as_ref())
139 else {
140 continue;
141 };
142 let size = crate::container_resources::parse_volume_size_gib(&storage.size)
143 .map_err(|reason| crate::container_resources::invalid(id, "volumeSizeGib", &reason))?;
144 if size < minimum || size > maximum {
145 return Err(AlienError::new(
146 ErrorData::ContainerResourceSelectionInvalid {
147 resource_id: id.clone(),
148 dimension: "volumeSizeGib".into(),
149 reason: format!(
150 "capacity for {platform} must be between {minimum} and {maximum} GiB"
151 ),
152 },
153 ));
154 }
155 }
156 if let Some(previous_state) = previous_state {
157 for (id, entry) in &resolved_stack.resources {
158 let desired = entry
159 .config
160 .downcast_ref::<Container>()
161 .and_then(|container| container.persistent_storage.as_ref());
162 let previous = previous_state
163 .resources
164 .get(id)
165 .and_then(|resource| resource.config.downcast_ref::<Container>())
166 .and_then(|container| container.persistent_storage.as_ref());
167 if previous.is_some() && desired.is_none() {
168 return Err(crate::container_resources::invalid(
169 id,
170 "volumeSizeGib",
171 "removing installed persistent storage is unsupported",
172 ));
173 }
174 if let (Some(desired), Some(previous)) = (desired, previous) {
175 if desired.mount_path != previous.mount_path {
176 return Err(crate::container_resources::invalid(
177 id,
178 "volumeSizeGib",
179 "changing an installed persistent storage mount path is unsupported",
180 ));
181 }
182 let target = crate::container_resources::parse_volume_size_gib(&desired.size);
183 let installed = crate::container_resources::parse_volume_size_gib(&previous.size);
184 if let (Ok(target), Ok(installed)) = (target, installed) {
185 if target < installed {
186 return Err(AlienError::new(ErrorData::ContainerResourceSelectionInvalid {
187 resource_id: id.clone(), dimension: "volumeSizeGib".into(),
188 reason: format!("existing volumes cannot shrink from {installed} to {target} GiB"),
189 }));
190 }
191 }
192 }
193 }
194 }
195 let stack = &resolved_stack;
196 let mut groups = collect_workload_groups(stack)?;
197 merge_explicit_compute_groups(stack, &mut groups)?;
198
199 let mut pool_ids: Vec<String> = groups.keys().cloned().collect();
200 pool_ids.sort();
201
202 let mut pools = Vec::new();
203 for pool_id in pool_ids {
204 let mut group = groups.remove(&pool_id).expect("pool id came from map keys");
205 let mut requirements = group.requirements;
206 let selected = selected_settings.and_then(|settings| settings.pools.get(&pool_id));
207 let declared_machine = stack
208 .resources
209 .values()
210 .filter_map(|entry| entry.config.downcast_ref::<crate::ComputeCluster>())
211 .flat_map(|cluster| &cluster.capacity_groups)
212 .find(|group| group.group_id == pool_id)
213 .and_then(|group| group.instance_type.as_deref());
214 let machine_override = match selected {
215 Some(selection) => selection.machine(),
216 None => declared_machine,
217 };
218 if requirements.architecture.is_none() {
219 requirements.architecture = machine_override
220 .and_then(|machine| instance_catalog::find_instance_type(platform, machine))
221 .map(|spec| spec.architecture)
222 .or_else(|| {
223 previous_state.and_then(|state| {
224 state
225 .resources
226 .values()
227 .filter_map(|resource| {
228 resource.config.downcast_ref::<crate::ComputeCluster>()
229 })
230 .flat_map(|cluster| &cluster.capacity_groups)
231 .find(|group| group.group_id == pool_id)
232 .and_then(|group| group.instance_type.as_deref())
233 .and_then(|machine| {
234 instance_catalog::find_instance_type(platform, machine)
235 })
236 .map(|spec| spec.architecture)
237 })
238 });
239 }
240 if group.generated {
241 group.scale = generated_pool_scale_policy(
242 group.scale.default_min_size(),
243 group.scale.default_max_size(),
244 selected.map(ComputePoolSelection::max_size),
245 );
246 }
247 let machines = selected
248 .map(ComputePoolSelection::max_size)
249 .unwrap_or(group.scale.default_max_size())
250 .max(1);
251 let mut sizing = requirements.clone();
252 sizing.max_cpu_per_container = sizing
254 .max_cpu_per_container
255 .max(sizing.total_cpu_at_desired / f64::from(machines));
256 sizing.max_memory_per_container = sizing.max_memory_per_container.max(
257 sizing
258 .total_memory_bytes_at_desired
259 .div_ceil(u64::from(machines)),
260 );
261 let recommended = recommended_selection(
262 platform,
263 &sizing,
264 &group.scale,
265 group.requires_failure_domain,
266 group.generated,
267 machine_override,
268 )?;
269 let mut selected_choice = selected.cloned().unwrap_or_else(|| recommended.clone());
270 if selected.is_none() {
271 if let Some(declared_machine) = declared_machine {
272 match &mut selected_choice {
273 ComputePoolSelection::Fixed { machine, .. }
274 | ComputePoolSelection::Autoscale { machine, .. } => {
275 *machine = Some(declared_machine.to_string());
276 }
277 }
278 }
279 }
280 match &mut selected_choice {
284 ComputePoolSelection::Fixed { machine, .. }
285 | ComputePoolSelection::Autoscale { machine, .. } => {
286 if machine.is_none() {
287 *machine = recommended.machine().map(ToString::to_string);
288 }
289 }
290 }
291 if selected_choice.failure_domains().is_none() {
292 if let Some(default_failure_domains) = recommended.failure_domains().cloned() {
293 match &mut selected_choice {
294 ComputePoolSelection::Fixed {
295 failure_domains, ..
296 }
297 | ComputePoolSelection::Autoscale {
298 failure_domains, ..
299 } => *failure_domains = Some(default_failure_domains),
300 }
301 }
302 }
303 let mut errors = validate_compute_pool_selection(
304 platform,
305 &pool_id,
306 &selected_choice,
307 &requirements,
308 &group.scale,
309 );
310 if let Some(workload_requirements) = &group.workload_requirements {
313 if let Err(message) =
314 check_pool_capacity(platform, &pool_id, &selected_choice, workload_requirements)
315 {
316 errors.push(message);
317 }
318 }
319 let machines = machine_options(
320 platform,
321 &requirements,
322 selected_choice.machine(),
323 recommended.machine(),
324 )?;
325
326 pools.push(ComputePoolPlan {
327 pool_id,
328 workloads: group.workloads,
329 requirements: requirements_to_profile(&requirements),
330 scale: group.scale,
331 selected: selected_choice,
332 recommended,
333 machines,
334 errors,
335 });
336 }
337
338 let mut containers: Vec<_> = stack
339 .resources
340 .values()
341 .filter_map(|entry| entry.config.downcast_ref::<Container>())
342 .filter(|container| {
343 container.resource_choices.is_some() || container.persistent_storage.is_some()
344 })
345 .map(|container| {
346 let capacity = |storage: &crate::PersistentStorage| {
347 crate::container_resources::parse_volume_size_gib(&storage.size).map_err(|reason| {
348 crate::container_resources::invalid(&container.id, "volumeSizeGib", &reason)
349 })
350 };
351 let volume_size_gib = container
352 .persistent_storage
353 .as_ref()
354 .map(capacity)
355 .transpose()?;
356 let installed = previous_state
357 .and_then(|state| state.resources.get(&container.id))
358 .and_then(|resource| resource.config.downcast_ref::<Container>())
359 .and_then(|container| container.persistent_storage.as_ref())
360 .map(capacity)
361 .transpose()?;
362 Ok(ContainerResourcePlan {
363 container_id: container.id.clone(),
364 choices: container.resource_choices.clone().unwrap_or_default(),
365 cpu: container.cpu.clone(),
366 memory: container.memory.clone(),
367 volume_size_gib,
368 minimum_volume_size_gib: volume_size_gib
369 .map(|_| installed.unwrap_or(if platform == Platform::Gcp { 10 } else { 1 })),
370 maximum_volume_size_gib: volume_size_gib.map(|_| {
371 if platform == Platform::Azure {
372 32767
373 } else {
374 65536
375 }
376 }),
377 })
378 })
379 .collect::<Result<Vec<_>, ErrorData>>()?;
380 containers.sort_by(|a, b| a.container_id.cmp(&b.container_id));
381 Ok(ComputePlan { pools, containers })
382}
383
384#[derive(Debug, Clone)]
385struct PlannedGroup {
386 workloads: Vec<String>,
387 workload_requirements: Option<WorkloadRequirements>,
389 requirements: WorkloadRequirements,
390 scale: CapacityGroupScalePolicy,
391 requires_failure_domain: bool,
392 generated: bool,
395}
396
397pub const GENERATED_POOL_MAX_MACHINES: u32 = 100;
402
403pub fn generated_pool_scale_policy(
415 default_min: u32,
416 default_max: u32,
417 selected_max: Option<u32>,
418) -> CapacityGroupScalePolicy {
419 let offered = GENERATED_POOL_MAX_MACHINES;
420 let ceiling = offered.max(selected_max.unwrap_or(0));
421 let default_min = default_min.min(offered);
422 CapacityGroupScalePolicy::Autoscale {
423 min: ComputeChoiceRange {
424 min: default_min,
425 max: ceiling,
426 default: default_min,
427 },
428 max: ComputeChoiceRange {
429 min: default_min.max(1),
430 max: ceiling,
431 default: default_max.clamp(default_min.max(1), offered),
432 },
433 }
434}
435
436pub fn check_pool_capacity(
440 platform: Platform,
441 pool_id: &str,
442 selection: &ComputePoolSelection,
443 requirements: &WorkloadRequirements,
444) -> std::result::Result<(), String> {
445 let Some(spec) = selection
446 .machine()
447 .and_then(|machine| instance_catalog::find_instance_type(platform, machine))
448 else {
449 return Ok(());
451 };
452 let machines = selection.max_size();
453 let per_machine_cpu = instance_catalog::allocatable_cpu(spec);
454 let per_machine_memory = instance_catalog::allocatable_memory_bytes(spec);
455 if per_machine_cpu + f64::EPSILON < requirements.max_cpu_per_container
456 || per_machine_memory < requirements.max_memory_per_container
457 {
458 return Err(format!(
459 "Pool '{pool_id}' machine '{}' cannot fit one replica after reserving host resources",
460 spec.name,
461 ));
462 }
463 let cpu = f64::from(machines) * per_machine_cpu;
464 let memory = u64::from(machines) * per_machine_memory;
465 if cpu + f64::EPSILON >= requirements.total_cpu_at_desired
466 && memory >= requirements.total_memory_bytes_at_desired
467 {
468 return Ok(());
469 }
470 const GIB: f64 = 1024.0 * 1024.0 * 1024.0;
471 Err(format!(
472 "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",
473 spec.name,
474 memory as f64 / GIB,
475 requirements.total_cpu_at_desired,
476 requirements.total_memory_bytes_at_desired as f64 / GIB,
477 ))
478}
479
480fn collect_workload_groups(stack: &Stack) -> Result<HashMap<String, PlannedGroup>, ErrorData> {
481 let mut groups: HashMap<String, Vec<Workload>> = HashMap::new();
482
483 for entry in stack.resources.values() {
484 if let Some(container) = entry.config.downcast_ref::<Container>() {
485 groups
486 .entry(
487 container
488 .pool
489 .clone()
490 .unwrap_or_else(|| needed_container_pool(container).to_string()),
491 )
492 .or_default()
493 .push(Workload::from_container(container)?);
494 }
495 if let Some(daemon) = entry.config.downcast_ref::<Daemon>() {
496 if daemon.cluster.is_some() {
497 groups
498 .entry(daemon.pool.clone().unwrap_or_else(|| "general".to_string()))
499 .or_default()
500 .push(Workload::from_daemon(daemon)?);
501 }
502 }
503 }
504
505 let mut planned = HashMap::new();
506 for (pool_id, workloads) in groups {
507 let requirements = aggregate_workloads(&workloads);
508 let requires_failure_domain = workloads
509 .iter()
510 .any(|workload| workload.requires_failure_domain);
511 let min_size = default_min_machines(&requirements);
512 let max_size = default_max_machines(&requirements);
513 planned.insert(
514 pool_id,
515 PlannedGroup {
516 workloads: workloads.into_iter().map(|w| w.id).collect(),
517 workload_requirements: Some(requirements.clone()),
518 scale: CapacityGroupScalePolicy::from_selected_bounds(min_size, max_size),
520 requirements,
521 requires_failure_domain,
522 generated: true,
523 },
524 );
525 }
526 Ok(planned)
527}
528
529fn merge_explicit_compute_groups(
530 stack: &Stack,
531 groups: &mut HashMap<String, PlannedGroup>,
532) -> Result<(), ErrorData> {
533 for entry in stack.resources.values() {
534 let Some(cluster) = entry.config.downcast_ref::<crate::ComputeCluster>() else {
535 continue;
536 };
537 for group in &cluster.capacity_groups {
538 let explicit_requirements = profile_to_requirements(
539 group.profile.as_ref(),
540 group.nested_virtualization.unwrap_or(false),
541 );
542 let scale = group.scale_policy.clone().unwrap_or_else(|| {
543 CapacityGroupScalePolicy::from_selected_bounds(group.min_size, group.max_size)
544 });
545 groups
546 .entry(group.group_id.clone())
547 .and_modify(|planned| {
548 if group.instance_type.is_none() {
549 merge_requirements(&mut planned.requirements, &explicit_requirements);
550 } else {
551 planned.requirements.architecture = planned
558 .requirements
559 .architecture
560 .or(explicit_requirements.architecture);
561 planned.requirements.nested_virt |= explicit_requirements.nested_virt;
562 }
563 planned.scale = merge_scale_policy(&planned.scale, &scale);
564 planned.generated = false;
565 })
566 .or_insert_with(|| PlannedGroup {
567 workloads: Vec::new(),
568 workload_requirements: None,
569 scale,
570 requirements: explicit_requirements,
571 requires_failure_domain: false,
572 generated: false,
573 });
574 }
575 }
576 Ok(())
577}
578
579fn recommended_selection(
580 platform: Platform,
581 requirements: &WorkloadRequirements,
582 scale: &CapacityGroupScalePolicy,
583 requires_failure_domain: bool,
584 generated: bool,
585 machine_override: Option<&str>,
586) -> Result<ComputePoolSelection, ErrorData> {
587 let machine = match platform {
588 Platform::Aws | Platform::Gcp | Platform::Azure => Some(
589 match instance_catalog::select_instance_type(platform, requirements) {
590 Ok(selection) => selection.instance_type.to_string(),
591 Err(message) => {
592 let fallback = machine_override
596 .and_then(|machine| instance_catalog::find_instance_type(platform, machine))
597 .filter(|spec| {
598 instance_satisfies(
599 spec,
600 requirements,
601 requirements.architecture.unwrap_or(spec.architecture),
602 )
603 });
604 fallback.map(|spec| spec.name.to_string()).ok_or_else(|| {
605 AlienError::new(ErrorData::GenericError {
606 message: format!("Failed to select {platform} machine: {message}"),
607 })
608 })?
609 }
610 },
611 ),
612 Platform::Local | Platform::Kubernetes | Platform::Machines | Platform::Test => None,
613 };
614
615 let failure_domains = (requires_failure_domain
616 && matches!(platform, Platform::Aws | Platform::Gcp | Platform::Azure))
617 .then(default_persistent_failure_domains);
618
619 match scale {
620 CapacityGroupScalePolicy::Fixed { machines } => Ok(ComputePoolSelection::Fixed {
621 machines: machines.default.max(1),
622 machine,
623 failure_domains,
624 }),
625 CapacityGroupScalePolicy::Autoscale { min, max }
627 if generated && min.default == max.default =>
628 {
629 Ok(ComputePoolSelection::Fixed {
630 machines: min.default.max(1),
631 machine,
632 failure_domains,
633 })
634 }
635 CapacityGroupScalePolicy::Autoscale { min, max } => Ok(ComputePoolSelection::Autoscale {
636 min: min.default,
637 max: max.default.max(min.default),
638 machine,
639 failure_domains,
640 }),
641 }
642}
643
644pub fn validate_compute_pool_selection(
647 platform: Platform,
648 pool_id: &str,
649 selection: &ComputePoolSelection,
650 requirements: &WorkloadRequirements,
651 scale: &CapacityGroupScalePolicy,
652) -> Vec<String> {
653 let mut errors = Vec::new();
654 if let Err(message) = selection.validate() {
655 errors.push(message);
656 }
657 if let Err(message) = validate_selection_against_scale(selection, scale) {
658 errors.push(format!("Pool '{pool_id}' {message}"));
659 }
660 if matches!(platform, Platform::Aws | Platform::Gcp | Platform::Azure) {
661 match selection.machine() {
662 Some(machine) => match instance_catalog::find_instance_type(platform, machine) {
663 Some(spec) => {
664 let architecture = requirements.architecture.unwrap_or(spec.architecture);
665 if !instance_satisfies(spec, requirements, architecture) {
666 errors.push(format!(
667 "{} machine '{}' does not satisfy pool '{}' requirements",
668 platform, machine, pool_id
669 ));
670 }
671 }
672 None => errors.push(format!(
673 "Unknown {} machine '{}' for pool '{}'",
674 platform, machine, pool_id
675 )),
676 },
677 None => errors.push(format!(
678 "Pool '{}' requires a provider machine on {}",
679 pool_id, platform
680 )),
681 }
682 }
683 errors
684}
685
686pub fn capacity_group_requirements(group: &CapacityGroup) -> WorkloadRequirements {
688 profile_to_requirements(
689 group.profile.as_ref(),
690 group.nested_virtualization.unwrap_or(false),
691 )
692}
693
694fn machine_options(
695 platform: Platform,
696 requirements: &WorkloadRequirements,
697 selected_machine: Option<&str>,
698 recommended_machine: Option<&str>,
699) -> Result<Vec<ComputeMachineOption>, ErrorData> {
700 if !matches!(platform, Platform::Aws | Platform::Gcp | Platform::Azure) {
701 return Ok(Vec::new());
702 }
703 let resolved_architecture = requirements
704 .architecture
705 .or_else(|| {
706 selected_machine.and_then(|machine| {
707 instance_catalog::find_instance_type(platform, machine)
708 .map(|spec| spec.architecture)
709 })
710 })
711 .or_else(|| {
712 recommended_machine
713 .and_then(|machine| instance_catalog::find_instance_type(platform, machine))
714 .map(|spec| spec.architecture)
715 })
716 .ok_or_else(|| {
717 AlienError::new(ErrorData::GenericError {
718 message: format!("Selected {platform} machine has no CPU architecture"),
719 })
720 })?;
721
722 let mut options: Vec<ComputeMachineOption> = instance_catalog::catalog_for_platform(platform)
723 .into_iter()
724 .filter(|spec| instance_satisfies(spec, requirements, resolved_architecture))
725 .map(|spec| ComputeMachineOption {
726 machine: spec.name.to_string(),
727 profile: spec.to_machine_profile_for_storage(requirements.max_ephemeral_storage_bytes),
728 recommended: Some(spec.name) == recommended_machine
729 || Some(spec.name) == selected_machine,
730 })
731 .collect();
732 options.sort_by(|a, b| a.machine.cmp(&b.machine));
733 Ok(options)
734}
735
736fn instance_satisfies(
737 spec: &instance_catalog::InstanceTypeSpec,
738 requirements: &WorkloadRequirements,
739 resolved_architecture: Architecture,
740) -> bool {
741 if spec.architecture != resolved_architecture {
742 return false;
743 }
744 if requirements.nested_virt && !spec.is_nested_virt_capable() {
745 return false;
746 }
747 if spec.vcpu < requirements.max_cpu_per_container.ceil() as u32 {
748 return false;
749 }
750 if spec.memory_bytes < requirements.max_memory_per_container {
751 return false;
752 }
753 if spec.has_configurable_ephemeral_storage() {
754 if crate::instance_catalog::max_configurable_ephemeral_storage_bytes(spec.platform)
755 .is_none_or(|max| requirements.max_ephemeral_storage_bytes > max)
756 {
757 return false;
758 }
759 } else if spec.ephemeral_storage_bytes < requirements.max_ephemeral_storage_bytes {
760 return false;
761 }
762 match (&requirements.gpu, spec.gpu) {
763 (Some(required), Some(actual)) => {
764 (required.gpu_type == "any" || required.gpu_type == actual.gpu_type)
765 && actual.count >= required.count
766 }
767 (Some(_), None) => false,
768 (None, _) => true,
769 }
770}
771
772#[derive(Debug, Clone)]
773struct Workload {
774 id: String,
775 cpu: f64,
776 memory_bytes: u64,
777 desired_replicas: f64,
778 max_replicas: f64,
779 ephemeral_storage_bytes: u64,
780 gpu: Option<GpuSpec>,
781 requires_failure_domain: bool,
782}
783
784impl Workload {
785 fn from_container(container: &Container) -> Result<Self, ErrorData> {
786 let cpu = parse_cpu(&container.id, &container.cpu)?;
787 let memory_bytes = parse_memory(&container.id, &container.memory)?;
788 let desired_replicas = container
789 .autoscaling
790 .as_ref()
791 .map(|a| a.desired)
792 .or(container.replicas)
793 .unwrap_or(1) as f64;
794 let max_replicas = container
795 .autoscaling
796 .as_ref()
797 .map(|a| a.max)
798 .or(container.replicas)
799 .unwrap_or(1) as f64;
800 let ephemeral_storage_bytes = container
801 .ephemeral_storage
802 .as_deref()
803 .map(instance_catalog::parse_memory_bytes)
804 .transpose()
805 .map_err(|message| {
806 AlienError::new(ErrorData::GenericError {
807 message: format!(
808 "Failed to parse ephemeral storage for '{}': {message}",
809 container.id
810 ),
811 })
812 })?
813 .unwrap_or(0);
814
815 Ok(Self {
816 id: container.id.clone(),
817 cpu,
818 memory_bytes,
819 desired_replicas,
820 max_replicas,
821 ephemeral_storage_bytes,
822 gpu: container.gpu.as_ref().map(|gpu| GpuSpec {
823 gpu_type: gpu.gpu_type.clone(),
824 count: gpu.count,
825 }),
826 requires_failure_domain: container.stateful && container.persistent_storage.is_some(),
827 })
828 }
829
830 fn from_daemon(daemon: &Daemon) -> Result<Self, ErrorData> {
831 Ok(Self {
832 id: daemon.id.clone(),
833 cpu: parse_cpu(&daemon.id, &daemon.cpu)?,
834 memory_bytes: parse_memory(&daemon.id, &daemon.memory)?,
835 desired_replicas: 1.0,
836 max_replicas: 1.0,
837 ephemeral_storage_bytes: 0,
838 gpu: None,
839 requires_failure_domain: false,
840 })
841 }
842}
843
844fn parse_cpu(resource_id: &str, spec: &ResourceSpec) -> Result<f64, ErrorData> {
845 instance_catalog::parse_cpu(&spec.desired).map_err(|message| {
846 AlienError::new(ErrorData::GenericError {
847 message: format!(
848 "Failed to parse CPU requirement '{}' for '{}': {message}",
849 spec.desired, resource_id
850 ),
851 })
852 })
853}
854
855fn parse_memory(resource_id: &str, spec: &ResourceSpec) -> Result<u64, ErrorData> {
856 instance_catalog::parse_memory_bytes(&spec.desired).map_err(|message| {
857 AlienError::new(ErrorData::GenericError {
858 message: format!(
859 "Failed to parse memory requirement '{}' for '{}': {message}",
860 spec.desired, resource_id
861 ),
862 })
863 })
864}
865
866fn aggregate_workloads(workloads: &[Workload]) -> WorkloadRequirements {
867 let mut requirements = default_requirements();
868 requirements.total_cpu_at_desired = 0.0;
869 requirements.total_memory_bytes_at_desired = 0;
870 requirements.total_cpu_at_max = 0.0;
871 requirements.total_memory_bytes_at_max = 0;
872 requirements.max_cpu_per_container = 0.0;
873 requirements.max_memory_per_container = 0;
874 requirements.max_ephemeral_storage_bytes = 0;
875 requirements.gpu = None;
876
877 for workload in workloads {
878 requirements.total_cpu_at_desired += workload.cpu * workload.desired_replicas;
879 requirements.total_cpu_at_max += workload.cpu * workload.max_replicas;
880 requirements.total_memory_bytes_at_desired +=
881 (workload.memory_bytes as f64 * workload.desired_replicas) as u64;
882 requirements.total_memory_bytes_at_max +=
883 (workload.memory_bytes as f64 * workload.max_replicas) as u64;
884 requirements.max_cpu_per_container = requirements.max_cpu_per_container.max(workload.cpu);
885 requirements.max_memory_per_container = requirements
886 .max_memory_per_container
887 .max(workload.memory_bytes);
888 requirements.max_ephemeral_storage_bytes = requirements
889 .max_ephemeral_storage_bytes
890 .max(workload.ephemeral_storage_bytes);
891 if requirements.gpu.is_none() {
892 requirements.gpu = workload.gpu.clone();
893 }
894 }
895 requirements
896}
897
898fn default_requirements() -> WorkloadRequirements {
899 WorkloadRequirements {
900 total_cpu_at_desired: 1.0,
901 total_memory_bytes_at_desired: 2 * 1024 * 1024 * 1024,
902 total_cpu_at_max: 1.0,
903 total_memory_bytes_at_max: 2 * 1024 * 1024 * 1024,
904 max_cpu_per_container: 1.0,
905 max_memory_per_container: 2 * 1024 * 1024 * 1024,
906 max_ephemeral_storage_bytes: 0,
907 gpu: None,
908 architecture: None,
909 nested_virt: false,
910 }
911}
912
913fn profile_to_requirements(
914 profile: Option<&MachineProfile>,
915 nested_virt: bool,
916) -> WorkloadRequirements {
917 let Some(profile) = profile else {
918 return WorkloadRequirements {
919 nested_virt,
920 ..default_requirements()
921 };
922 };
923 let cpu = instance_catalog::parse_cpu(&profile.cpu).unwrap_or(1.0);
924 WorkloadRequirements {
925 total_cpu_at_desired: cpu,
926 total_memory_bytes_at_desired: profile.memory_bytes,
927 total_cpu_at_max: cpu,
928 total_memory_bytes_at_max: profile.memory_bytes,
929 max_cpu_per_container: cpu,
930 max_memory_per_container: profile.memory_bytes,
931 max_ephemeral_storage_bytes: profile.ephemeral_storage_bytes,
932 gpu: profile.gpu.clone(),
933 architecture: profile.architecture,
934 nested_virt,
935 }
936}
937
938fn merge_requirements(existing: &mut WorkloadRequirements, declared: &WorkloadRequirements) {
939 existing.total_cpu_at_desired = existing
940 .total_cpu_at_desired
941 .max(declared.total_cpu_at_desired);
942 existing.total_memory_bytes_at_desired = existing
943 .total_memory_bytes_at_desired
944 .max(declared.total_memory_bytes_at_desired);
945 existing.total_cpu_at_max = existing.total_cpu_at_max.max(declared.total_cpu_at_max);
946 existing.total_memory_bytes_at_max = existing
947 .total_memory_bytes_at_max
948 .max(declared.total_memory_bytes_at_max);
949 existing.max_cpu_per_container = existing
950 .max_cpu_per_container
951 .max(declared.max_cpu_per_container);
952 existing.max_memory_per_container = existing
953 .max_memory_per_container
954 .max(declared.max_memory_per_container);
955 existing.max_ephemeral_storage_bytes = existing
956 .max_ephemeral_storage_bytes
957 .max(declared.max_ephemeral_storage_bytes);
958 if existing.gpu.is_none() {
959 existing.gpu = declared.gpu.clone();
960 }
961 existing.nested_virt |= declared.nested_virt;
962 if existing.architecture.is_none() {
963 existing.architecture = declared.architecture;
964 }
965}
966
967fn requirements_to_profile(requirements: &WorkloadRequirements) -> MachineProfile {
968 MachineProfile {
969 cpu: requirements.max_cpu_per_container.to_string(),
970 memory_bytes: requirements.max_memory_per_container,
971 ephemeral_storage_bytes: requirements.max_ephemeral_storage_bytes,
972 architecture: requirements.architecture,
973 gpu: requirements.gpu.clone(),
974 }
975}
976
977fn merge_scale_policy(
978 existing: &CapacityGroupScalePolicy,
979 declared: &CapacityGroupScalePolicy,
980) -> CapacityGroupScalePolicy {
981 match (existing, declared) {
982 (
983 CapacityGroupScalePolicy::Fixed {
984 machines: existing_machines,
985 },
986 CapacityGroupScalePolicy::Fixed {
987 machines: declared_machines,
988 },
989 ) => CapacityGroupScalePolicy::Fixed {
990 machines: merge_choice_range(existing_machines, declared_machines),
991 },
992 (_, declared) => declared.clone(),
993 }
994}
995
996fn merge_choice_range(
997 existing: &ComputeChoiceRange,
998 declared: &ComputeChoiceRange,
999) -> ComputeChoiceRange {
1000 ComputeChoiceRange {
1001 min: existing.min.max(declared.min),
1002 max: existing.max.max(declared.max),
1003 default: declared.default,
1004 }
1005}
1006
1007fn validate_selection_against_scale(
1008 selection: &ComputePoolSelection,
1009 scale: &CapacityGroupScalePolicy,
1010) -> std::result::Result<(), String> {
1011 match (selection, scale) {
1014 (
1015 ComputePoolSelection::Fixed { machines, .. },
1016 CapacityGroupScalePolicy::Fixed { machines: allowed },
1017 ) => check_machine_count("fixed machine count", *machines, allowed.min, allowed.max),
1018 (
1019 ComputePoolSelection::Autoscale { min, max, .. },
1020 CapacityGroupScalePolicy::Autoscale {
1021 min: allowed_min,
1022 max: allowed_max,
1023 },
1024 ) => {
1025 check_machine_count("autoscale minimum", *min, allowed_min.min, allowed_min.max)?;
1026 check_machine_count("autoscale maximum", *max, allowed_max.min, allowed_max.max)
1027 }
1028 (
1029 ComputePoolSelection::Fixed { machines, .. },
1030 CapacityGroupScalePolicy::Autoscale {
1031 min: allowed_min,
1032 max: allowed_max,
1033 },
1034 ) => {
1035 let lowest = allowed_min.min.max(allowed_max.min);
1036 let highest = allowed_min.max.min(allowed_max.max);
1037 if lowest > highest {
1038 return Err(format!(
1039 "must use autoscale mode (minimum {}-{}, maximum {}-{})",
1040 allowed_min.min, allowed_min.max, allowed_max.min, allowed_max.max
1041 ));
1042 }
1043 check_machine_count("fixed machine count", *machines, lowest, highest)
1044 }
1045 (
1046 ComputePoolSelection::Autoscale { min, max, .. },
1047 CapacityGroupScalePolicy::Fixed { machines: allowed },
1048 ) => {
1049 if min != max {
1050 return Err(format!(
1051 "must use fixed mode with {}-{} machines; autoscale {min}-{max} is not allowed",
1052 allowed.min, allowed.max
1053 ));
1054 }
1055 check_machine_count("machine count", *min, allowed.min, allowed.max)
1056 }
1057 }
1058}
1059
1060fn check_machine_count(
1061 label: &str,
1062 value: u32,
1063 lowest: u32,
1064 highest: u32,
1065) -> std::result::Result<(), String> {
1066 if (lowest..=highest).contains(&value) {
1067 Ok(())
1068 } else {
1069 Err(format!(
1070 "{label} {value} is outside the allowed range {lowest}-{highest}"
1071 ))
1072 }
1073}
1074
1075fn needed_container_pool(container: &Container) -> &'static str {
1076 if container.stateful && container.persistent_storage.is_some() {
1077 return "stateful";
1078 }
1079 if container.gpu.is_some() {
1080 return "gpu";
1081 }
1082 if let Some(storage) = &container.ephemeral_storage {
1083 if instance_catalog::parse_memory_bytes(storage).unwrap_or(0) > 200 * 1024 * 1024 * 1024 {
1084 return "storage";
1085 }
1086 }
1087 "general"
1088}
1089
1090fn default_min_machines(requirements: &WorkloadRequirements) -> u32 {
1091 if requirements.total_cpu_at_desired > 0.0 || requirements.total_memory_bytes_at_desired > 0 {
1092 1
1093 } else {
1094 0
1095 }
1096}
1097
1098fn default_max_machines(requirements: &WorkloadRequirements) -> u32 {
1099 let min = default_min_machines(requirements);
1100 let by_cpu =
1101 (requirements.total_cpu_at_max / requirements.max_cpu_per_container.max(1.0)).ceil() as u32;
1102 let by_mem = requirements
1103 .total_memory_bytes_at_max
1104 .div_ceil(requirements.max_memory_per_container.max(1)) as u32;
1105 min.max(by_cpu).max(by_mem).max(1)
1106}
1107
1108#[cfg(test)]
1109mod tests {
1110 use super::*;
1111 use crate::{
1112 instance_catalog::Architecture, CapacityGroup, CapacityGroupScalePolicy,
1113 ComputeChoiceRange, ComputeCluster, ComputeSettings, ContainerCode, DaemonCode, Resource,
1114 ResourceEntry, ResourceLifecycle, Stack, Storage, Worker, WorkerCode,
1115 };
1116
1117 #[test]
1118 fn deployment_resources_drive_machine_planning_on_every_cloud() {
1119 let mut stack = stack_with_container();
1120 let container = stack
1121 .resources
1122 .get_mut("api")
1123 .unwrap()
1124 .config
1125 .downcast_mut::<Container>()
1126 .unwrap();
1127 container.resource_choices = Some(crate::ContainerResourceChoices {
1128 cpu: Some(crate::ResourceChoiceRange {
1129 min: "0.5".into(),
1130 max: "8".into(),
1131 default: "2".into(),
1132 }),
1133 memory: Some(crate::ResourceChoiceRange {
1134 min: "512Mi".into(),
1135 max: "16Gi".into(),
1136 default: "4Gi".into(),
1137 }),
1138 });
1139 let settings: ComputeSettings = serde_json::from_value(serde_json::json!({
1140 "pools": { "general": { "mode": "fixed", "machines": 1 } },
1141 "containers": { "api": { "cpu": 8, "memory": "16Gi" } }
1142 }))
1143 .unwrap();
1144 for platform in [Platform::Aws, Platform::Gcp, Platform::Azure] {
1145 let default_plan = plan_compute(&stack, platform, None).unwrap();
1146 let selected_plan = plan_compute(&stack, platform, Some(&settings)).unwrap();
1147 let pool = &selected_plan.pools[0];
1148 assert!(pool.errors.is_empty(), "{platform}: {:?}", pool.errors);
1149 assert_eq!(pool.requirements.cpu, "8");
1150 assert_eq!(pool.requirements.memory_bytes, 16 * 1024 * 1024 * 1024);
1151 assert_ne!(
1152 pool.selected.machine(),
1153 default_plan.pools[0].selected.machine()
1154 );
1155 assert_eq!(selected_plan.containers[0].cpu.min, "8");
1156 assert_eq!(selected_plan.containers[0].memory.desired, "16Gi");
1157 assert_eq!(pool.selected.min_size(), 1);
1158 assert_eq!(pool.selected.max_size(), 1);
1159 }
1160 assert_eq!(
1161 settings.pools["general"].machine(),
1162 None,
1163 "resolving a plan must preserve automatic machine intent"
1164 );
1165 let original = stack.resources["api"]
1166 .config
1167 .downcast_ref::<Container>()
1168 .unwrap();
1169 assert_eq!(
1170 original.cpu.desired, "2",
1171 "planning must not mutate release defaults"
1172 );
1173 }
1174
1175 #[test]
1176 fn explicit_machine_can_fit_a_low_cpu_high_memory_workload() {
1177 let mut stack = stack_with_container();
1178 let container = stack
1179 .resources
1180 .get_mut("api")
1181 .unwrap()
1182 .config
1183 .downcast_mut::<Container>()
1184 .unwrap();
1185 container.cpu = ResourceSpec {
1186 min: "0.5".into(),
1187 desired: "0.5".into(),
1188 };
1189 container.memory = ResourceSpec {
1190 min: "8Gi".into(),
1191 desired: "8Gi".into(),
1192 };
1193 let settings: ComputeSettings = serde_json::from_value(serde_json::json!({
1194 "pools": {"general":{"mode":"fixed","machines":1,"machine":"n2-standard-4"}}
1195 }))
1196 .unwrap();
1197 let plan = plan_compute(&stack, Platform::Gcp, Some(&settings)).unwrap();
1198 assert!(
1199 plan.pools[0].errors.is_empty(),
1200 "{:?}",
1201 plan.pools[0].errors
1202 );
1203 assert_eq!(plan.pools[0].selected.machine(), Some("n2-standard-4"));
1204 }
1205
1206 #[test]
1207 fn pin_must_leave_host_capacity_for_one_replica() {
1208 let mut stack = stack_with_container();
1209 stack
1210 .resources
1211 .get_mut("api")
1212 .unwrap()
1213 .config
1214 .downcast_mut::<Container>()
1215 .unwrap()
1216 .cpu = ResourceSpec {
1217 min: "4".into(),
1218 desired: "4".into(),
1219 };
1220 let settings: ComputeSettings = serde_json::from_value(serde_json::json!({
1221 "pools":{"general":{"mode":"fixed","machines":1,"machine":"t3.xlarge"}}
1222 }))
1223 .unwrap();
1224 let plan = plan_compute(&stack, Platform::Aws, Some(&settings)).unwrap();
1225 assert!(
1226 plan.pools[0]
1227 .errors
1228 .iter()
1229 .any(|error| error.contains("host resources")),
1230 "{:?}",
1231 plan.pools[0].errors
1232 );
1233 }
1234
1235 #[test]
1236 fn valid_pin_does_not_require_automatic_recommendation_headroom() {
1237 let machine = instance_catalog::catalog_for_platform(Platform::Gcp)
1238 .into_iter()
1239 .max_by_key(|machine| machine.memory_bytes)
1240 .unwrap();
1241 let mut stack = stack_with_container();
1242 let container = stack
1243 .resources
1244 .get_mut("api")
1245 .unwrap()
1246 .config
1247 .downcast_mut::<Container>()
1248 .unwrap();
1249 container.cpu = ResourceSpec {
1250 min: "0.5".into(),
1251 desired: "0.5".into(),
1252 };
1253 let memory = instance_catalog::allocatable_memory_bytes(machine).to_string();
1254 container.memory = ResourceSpec {
1255 min: memory.clone(),
1256 desired: memory,
1257 };
1258 assert!(plan_compute(&stack, Platform::Gcp, None).is_err());
1259 let settings: ComputeSettings = serde_json::from_value(serde_json::json!({
1260 "pools":{"general":{"mode":"fixed","machines":1,"machine":machine.name}}
1261 }))
1262 .unwrap();
1263 let plan = plan_compute(&stack, Platform::Gcp, Some(&settings)).unwrap();
1264 assert!(
1265 plan.pools[0].errors.is_empty(),
1266 "{:?}",
1267 plan.pools[0].errors
1268 );
1269 assert_eq!(plan.pools[0].selected.machine(), Some(machine.name));
1270 }
1271
1272 #[test]
1273 fn invalid_deployment_resources_fail_before_capacity_planning() {
1274 let mut stack = stack_with_container();
1275 stack
1276 .resources
1277 .get_mut("api")
1278 .unwrap()
1279 .config
1280 .downcast_mut::<Container>()
1281 .unwrap()
1282 .resource_choices = Some(crate::ContainerResourceChoices {
1283 cpu: Some(crate::ResourceChoiceRange {
1284 min: "0.5".into(),
1285 max: "4".into(),
1286 default: "2".into(),
1287 }),
1288 memory: Some(crate::ResourceChoiceRange {
1289 min: "512Mi".into(),
1290 max: "8Gi".into(),
1291 default: "4Gi".into(),
1292 }),
1293 });
1294 for selection in [
1295 serde_json::json!({"api":{"cpu": 0}}),
1296 serde_json::json!({"api":{"cpu": 5}}),
1297 serde_json::json!({"api":{"memory": "16Gi"}}),
1298 serde_json::json!({"api":{"memory": "NaNGi"}}),
1299 serde_json::json!({"api":{"memory": "-1Gi"}}),
1300 serde_json::json!({"missing":{"cpu": 1}}),
1301 ] {
1302 let settings: ComputeSettings =
1303 serde_json::from_value(serde_json::json!({"containers": selection})).unwrap();
1304 let error = plan_compute(&stack, Platform::Aws, Some(&settings)).unwrap_err();
1305 assert_eq!(error.code, "CONTAINER_RESOURCE_SELECTION_INVALID");
1306 }
1307 }
1308
1309 #[test]
1310 fn deployment_volume_selection_is_applied_without_changing_release_defaults() {
1311 let mut release = stack_with_container();
1312 release
1313 .resources
1314 .get_mut("api")
1315 .unwrap()
1316 .config
1317 .downcast_mut::<Container>()
1318 .unwrap()
1319 .persistent_storage = Some(crate::PersistentStorage {
1320 size: "20Gi".into(),
1321 mount_path: "/data".into(),
1322 backups: crate::VolumeBackups::default(),
1323 encryption_key: None,
1324 });
1325 let settings: ComputeSettings = serde_json::from_value(serde_json::json!({
1326 "containers": {"api": {"volumeSizeGib": 40}}
1327 }))
1328 .unwrap();
1329 let mut deployment = release.clone();
1330 crate::container_resources::resolve_container_resources(&mut deployment, Some(&settings))
1331 .unwrap();
1332 let volume = |stack: &Stack| {
1333 stack.resources["api"]
1334 .config
1335 .downcast_ref::<Container>()
1336 .unwrap()
1337 .persistent_storage
1338 .as_ref()
1339 .unwrap()
1340 .size
1341 .clone()
1342 };
1343 assert_eq!(volume(&deployment), "40Gi");
1344 assert_eq!(volume(&release), "20Gi");
1345 let mut equivalent = release.clone();
1346 equivalent
1347 .resources
1348 .get_mut("api")
1349 .unwrap()
1350 .config
1351 .downcast_mut::<Container>()
1352 .unwrap()
1353 .persistent_storage
1354 .as_mut()
1355 .unwrap()
1356 .size = "1Ti".into();
1357 let same_capacity: ComputeSettings = serde_json::from_value(serde_json::json!({
1358 "containers": {"api": {"volumeSizeGib": 1024}}
1359 }))
1360 .unwrap();
1361 crate::container_resources::resolve_container_resources(
1362 &mut equivalent,
1363 Some(&same_capacity),
1364 )
1365 .unwrap();
1366 assert_eq!(
1367 volume(&equivalent),
1368 "1Ti",
1369 "equivalent selections preserve PVC quantities"
1370 );
1371 crate::container_resources::resolve_container_resources(&mut deployment, Some(&settings))
1372 .unwrap();
1373 assert_eq!(volume(&deployment), "40Gi");
1374 let smaller_initial: ComputeSettings = serde_json::from_value(serde_json::json!({
1375 "containers": {"api": {"volumeSizeGib": 10}}
1376 }))
1377 .unwrap();
1378 crate::container_resources::resolve_container_resources(
1379 &mut release.clone(),
1380 Some(&smaller_initial),
1381 )
1382 .unwrap();
1383 let mut installed = StackState::new(Platform::Aws);
1384 installed.resources.insert(
1385 "api".into(),
1386 crate::StackResourceState::new_pending(
1387 "Container".into(),
1388 deployment.resources["api"].config.clone(),
1389 None,
1390 vec![],
1391 ),
1392 );
1393 let shrinking: ComputeSettings = serde_json::from_value(serde_json::json!({
1394 "containers": {"api": {"volumeSizeGib": 30}}
1395 }))
1396 .unwrap();
1397 let error =
1398 plan_compute_with_state(&release, Platform::Aws, Some(&shrinking), Some(&installed))
1399 .unwrap_err();
1400 assert_eq!(error.code, "CONTAINER_RESOURCE_SELECTION_INVALID");
1401 assert!(error.to_string().contains("cannot shrink from 40 to 30"));
1402 let imported: ComputeSettings = serde_json::from_value(serde_json::json!({
1403 "containers": {"api": {"volumeSizeGib": "40"}}
1404 }))
1405 .unwrap();
1406 assert_eq!(imported.containers["api"].volume_size_gib, Some(40));
1407 for value in [
1408 serde_json::json!(-1),
1409 serde_json::json!(1.5),
1410 serde_json::json!("4294967296"),
1411 ] {
1412 assert!(
1413 serde_json::from_value::<ComputeSettings>(serde_json::json!({
1414 "containers": {"api": {"volumeSizeGib": value}}
1415 }))
1416 .is_err()
1417 );
1418 }
1419 for (platform, size) in [(Platform::Gcp, 9), (Platform::Azure, 32768)] {
1420 let invalid: ComputeSettings = serde_json::from_value(serde_json::json!({
1421 "containers": {"api": {"volumeSizeGib": size}}
1422 }))
1423 .unwrap();
1424 let error = plan_compute(&release, platform, Some(&invalid)).unwrap_err();
1425 assert_eq!(error.code, "CONTAINER_RESOURCE_SELECTION_INVALID");
1426 assert!(error.to_string().contains("must be between"));
1427 }
1428 for size in [0, 65537] {
1429 let invalid: ComputeSettings = serde_json::from_value(serde_json::json!({
1430 "containers": {"api": {"volumeSizeGib": size}}
1431 }))
1432 .unwrap();
1433 assert_eq!(
1434 crate::container_resources::resolve_container_resources(
1435 &mut release.clone(),
1436 Some(&invalid)
1437 )
1438 .unwrap_err()
1439 .code,
1440 "CONTAINER_RESOURCE_SELECTION_INVALID"
1441 );
1442 }
1443 assert_eq!(
1444 crate::container_resources::resolve_container_resources(
1445 &mut stack_with_container(),
1446 Some(&settings)
1447 )
1448 .unwrap_err()
1449 .code,
1450 "CONTAINER_RESOURCE_SELECTION_INVALID"
1451 );
1452 }
1453
1454 fn stack_with_volume(size: &str) -> Stack {
1455 let mut stack = stack_with_container();
1456 stack
1457 .resources
1458 .get_mut("api")
1459 .unwrap()
1460 .config
1461 .downcast_mut::<Container>()
1462 .unwrap()
1463 .persistent_storage = Some(crate::PersistentStorage {
1464 size: size.into(),
1465 mount_path: "/data".into(),
1466 backups: crate::VolumeBackups::default(),
1467 encryption_key: None,
1468 });
1469 stack
1470 }
1471
1472 fn installed_with_volume(platform: Platform, size: &str) -> StackState {
1473 let mut installed = StackState::new(platform);
1474 installed.resources.insert(
1475 "api".into(),
1476 crate::StackResourceState::new_pending(
1477 "Container".into(),
1478 stack_with_volume(size).resources["api"].config.clone(),
1479 None,
1480 vec![],
1481 ),
1482 );
1483 installed
1484 }
1485
1486 fn api_volume(plan: &ComputePlan) -> (Option<u32>, Option<u32>) {
1487 let api = plan
1488 .containers
1489 .iter()
1490 .find(|container| container.container_id == "api")
1491 .expect("api is planned");
1492 (api.volume_size_gib, api.minimum_volume_size_gib)
1493 }
1494
1495 #[test]
1499 fn a_lower_release_default_keeps_installed_volumes_without_a_selection() {
1500 for platform in [Platform::Aws, Platform::Gcp, Platform::Azure] {
1501 let installed = installed_with_volume(platform, "30Gi");
1502 let plan = plan_compute_with_state(
1503 &stack_with_volume("20Gi"),
1504 platform,
1505 None,
1506 Some(&installed),
1507 )
1508 .expect("a lower release default keeps the installed volume");
1509 assert_eq!(api_volume(&plan), (Some(30), Some(30)), "{platform}");
1510
1511 let plan = plan_compute_with_state(
1512 &stack_with_volume("40Gi"),
1513 platform,
1514 None,
1515 Some(&installed),
1516 )
1517 .expect("a larger release default grows the volume");
1518 assert_eq!(api_volume(&plan), (Some(40), Some(30)), "{platform}");
1519
1520 let shrinking: ComputeSettings = serde_json::from_value(serde_json::json!({
1522 "containers": {"api": {"volumeSizeGib": 25}}
1523 }))
1524 .unwrap();
1525 let error = plan_compute_with_state(
1526 &stack_with_volume("20Gi"),
1527 platform,
1528 Some(&shrinking),
1529 Some(&installed),
1530 )
1531 .unwrap_err();
1532 assert!(
1533 error.to_string().contains("cannot shrink from 30 to 25"),
1534 "{error}"
1535 );
1536 }
1537 }
1538
1539 #[test]
1543 fn setup_source_volume_sizes_below_installed_keep_the_installed_size() {
1544 let mut installed = installed_with_volume(Platform::Aws, "30Gi");
1545 let mut worker = stack_with_volume("10Gi").resources["api"].config.clone();
1546 worker
1547 .downcast_mut::<Container>()
1548 .unwrap()
1549 .persistent_storage = None;
1550 installed.resources.insert(
1551 "worker".into(),
1552 crate::StackResourceState::new_pending("Container".into(), worker, None, vec![]),
1553 );
1554 let mut settings: ComputeSettings = serde_json::from_value(serde_json::json!({
1555 "containers": {
1556 "api": {"volumeSizeGib": 25, "cpu": 2},
1557 "worker": {"volumeSizeGib": 5},
1558 "new": {"volumeSizeGib": 8}
1559 }
1560 }))
1561 .unwrap();
1562
1563 let kept = crate::container_resources::keep_volume_sizes_over_setup_source(
1564 &mut settings,
1565 Some(&installed),
1566 None,
1567 );
1568
1569 assert_eq!(
1570 kept,
1571 vec![crate::container_resources::KeptVolumeSize {
1572 container_id: "api".into(),
1573 requested_gib: 25,
1574 kept_gib: 30,
1575 }]
1576 );
1577 assert_eq!(settings.containers["api"].volume_size_gib, Some(30));
1578 assert_eq!(
1579 settings.containers["api"].cpu,
1580 Some(serde_json::Number::from(2))
1581 );
1582 assert_eq!(settings.containers["worker"].volume_size_gib, Some(5));
1583 assert_eq!(settings.containers["new"].volume_size_gib, Some(8));
1584 let plan = plan_compute_with_state(
1585 &stack_with_volume("20Gi"),
1586 Platform::Aws,
1587 Some(&ComputeSettings {
1588 containers: [("api".to_string(), settings.containers["api"].clone())].into(),
1589 ..Default::default()
1590 }),
1591 Some(&installed_with_volume(Platform::Aws, "30Gi")),
1592 )
1593 .expect("the kept size plans");
1594 assert_eq!(api_volume(&plan), (Some(30), Some(30)));
1595
1596 let mut growing: ComputeSettings = serde_json::from_value(serde_json::json!({
1597 "containers": {"api": {"volumeSizeGib": 35}}
1598 }))
1599 .unwrap();
1600 assert!(
1601 crate::container_resources::keep_volume_sizes_over_setup_source(
1602 &mut growing,
1603 Some(&installed),
1604 None,
1605 )
1606 .is_empty()
1607 );
1608 assert_eq!(growing.containers["api"].volume_size_gib, Some(35));
1609
1610 let chosen: ComputeSettings = serde_json::from_value(serde_json::json!({
1612 "containers": {"api": {"volumeSizeGib": 40}}
1613 }))
1614 .unwrap();
1615 let mut stale: ComputeSettings = serde_json::from_value(serde_json::json!({
1616 "containers": {"api": {"volumeSizeGib": 25}}
1617 }))
1618 .unwrap();
1619 let kept = crate::container_resources::keep_volume_sizes_over_setup_source(
1620 &mut stale,
1621 Some(&installed),
1622 Some(&chosen),
1623 );
1624 assert_eq!(kept[0].kept_gib, 40);
1625 assert_eq!(stale.containers["api"].volume_size_gib, Some(40));
1626
1627 let mut correction: ComputeSettings = serde_json::from_value(serde_json::json!({
1630 "containers": {"api": {"volumeSizeGib": 20}}
1631 }))
1632 .unwrap();
1633 for nothing_installed in [None, Some(&StackState::new(Platform::Aws))] {
1634 assert!(
1635 crate::container_resources::keep_volume_sizes_over_setup_source(
1636 &mut correction,
1637 nothing_installed,
1638 Some(&chosen),
1639 )
1640 .is_empty()
1641 );
1642 assert_eq!(correction.containers["api"].volume_size_gib, Some(20));
1643 }
1644 }
1645
1646 #[test]
1647 fn installed_storage_cannot_be_removed_or_remounted_on_any_provider() {
1648 for platform in [
1649 Platform::Aws,
1650 Platform::Gcp,
1651 Platform::Azure,
1652 Platform::Kubernetes,
1653 ] {
1654 let mut release = stack_with_container();
1655 release
1656 .resources
1657 .get_mut("api")
1658 .unwrap()
1659 .config
1660 .downcast_mut::<Container>()
1661 .unwrap()
1662 .persistent_storage = Some(crate::PersistentStorage {
1663 size: "20Gi".into(),
1664 mount_path: "/data".into(),
1665 backups: crate::VolumeBackups::default(),
1666 encryption_key: None,
1667 });
1668 let mut installed = StackState::new(platform);
1669 installed.resources.insert(
1670 "api".into(),
1671 crate::StackResourceState::new_pending(
1672 "Container".into(),
1673 release.resources["api"].config.clone(),
1674 None,
1675 vec![],
1676 ),
1677 );
1678 for remove in [false, true] {
1679 let mut desired = release.clone();
1680 let storage = &mut desired
1681 .resources
1682 .get_mut("api")
1683 .unwrap()
1684 .config
1685 .downcast_mut::<Container>()
1686 .unwrap()
1687 .persistent_storage;
1688 if remove {
1689 *storage = None;
1690 } else {
1691 storage.as_mut().unwrap().mount_path = "/other".into();
1692 }
1693 let error = plan_compute_with_state(&desired, platform, None, Some(&installed))
1694 .unwrap_err();
1695 assert_eq!(error.code, "CONTAINER_RESOURCE_SELECTION_INVALID");
1696 assert!(
1697 error.to_string().contains(if remove {
1698 "removing installed"
1699 } else {
1700 "mount path"
1701 }),
1702 "{error}"
1703 );
1704 }
1705 }
1706 }
1707
1708 #[test]
1709 fn release_volume_defaults_obey_provider_bounds_without_a_selection() {
1710 for (platform, size) in [(Platform::Gcp, "5Gi"), (Platform::Azure, "40000Gi")] {
1711 let mut stack = stack_with_container();
1712 stack
1713 .resources
1714 .get_mut("api")
1715 .unwrap()
1716 .config
1717 .downcast_mut::<Container>()
1718 .unwrap()
1719 .persistent_storage = Some(crate::PersistentStorage {
1720 size: size.into(),
1721 mount_path: "/data".into(),
1722 backups: crate::VolumeBackups::default(),
1723 encryption_key: None,
1724 });
1725 let error = plan_compute(&stack, platform, None).unwrap_err();
1726 assert_eq!(error.code, "CONTAINER_RESOURCE_SELECTION_INVALID");
1727 assert!(error.to_string().contains("must be between"));
1728 }
1729 }
1730
1731 #[test]
1732 fn volume_capacity_parsing_rejects_overflow_and_preserves_legacy_units() {
1733 use crate::container_resources::parse_volume_size_gib;
1734 for (value, size) in [
1735 ("20Gi", 20),
1736 ("20GiB", 20),
1737 ("20GB", 20),
1738 ("1Ti", 1024),
1739 ("1TB", 1000),
1740 ("1.5Ti", 1536),
1741 ("1536Mi", 2),
1742 ("0.5Gi", 1),
1743 ("1073741824", 1),
1744 ] {
1745 assert_eq!(parse_volume_size_gib(value).unwrap(), size);
1746 }
1747 for value in ["0Gi", "-1Gi", "NaNGi", "4294967295Ti", "65537Gi"] {
1748 assert!(parse_volume_size_gib(value).is_err(), "{value}");
1749 }
1750 }
1751
1752 fn stack_with_container() -> Stack {
1753 let container = Container::new("api".to_string())
1754 .code(ContainerCode::Image {
1755 image: "api:latest".to_string(),
1756 })
1757 .cpu(ResourceSpec {
1758 min: "1".to_string(),
1759 desired: "2".to_string(),
1760 })
1761 .memory(ResourceSpec {
1762 min: "2Gi".to_string(),
1763 desired: "4Gi".to_string(),
1764 })
1765 .permissions("api".to_string())
1766 .build();
1767 Stack {
1768 id: "test".to_string(),
1769 resources: [(
1770 "api".to_string(),
1771 ResourceEntry {
1772 config: Resource::new(container),
1773 lifecycle: ResourceLifecycle::Live,
1774 dependencies: Vec::new(),
1775 remote_access: false,
1776 enabled_when: None,
1777 },
1778 )]
1779 .into_iter()
1780 .collect(),
1781 permissions: crate::permissions::PermissionsConfig::default(),
1782 supported_platforms: None,
1783 inputs: vec![],
1784 dynamic_container_repositories: Vec::new(),
1785 dynamic_container_image_resources: Vec::new(),
1786 operations: None,
1787 }
1788 }
1789
1790 #[test]
1791 fn serverless_workers_and_storage_do_not_allocate_machine_pools() {
1792 let mut stack = stack_with_container();
1793 stack.resources.clear();
1794 for (id, resource) in [
1795 (
1796 "api",
1797 Resource::new(
1798 Worker::new("api".to_string())
1799 .code(WorkerCode::Image {
1800 image: "example/worker:v1".to_string(),
1801 })
1802 .permissions("execution".to_string())
1803 .build(),
1804 ),
1805 ),
1806 (
1807 "data",
1808 Resource::new(Storage::new("data".to_string()).build()),
1809 ),
1810 ] {
1811 stack.resources.insert(
1812 id.to_string(),
1813 ResourceEntry {
1814 config: resource,
1815 lifecycle: ResourceLifecycle::Frozen,
1816 dependencies: Vec::new(),
1817 remote_access: false,
1818 enabled_when: None,
1819 },
1820 );
1821 }
1822
1823 for platform in [Platform::Aws, Platform::Gcp, Platform::Azure] {
1824 let plan = plan_compute(&stack, platform, None).expect("plan should build");
1825 assert!(plan.pools.is_empty(), "serverless plan allocated {plan:?}");
1826 }
1827 }
1828
1829 #[test]
1830 fn empty_stack_does_not_allocate_machine_pools() {
1831 let mut stack = stack_with_container();
1832 stack.resources.clear();
1833 let plan = plan_compute(&stack, Platform::Aws, None).expect("plan should build");
1834 assert!(plan.pools.is_empty());
1835 }
1836
1837 #[test]
1838 fn explicit_cluster_without_workloads_only_plans_declared_pools() {
1839 let mut stack = stack_with_container();
1840 stack.resources.clear();
1841 let cluster = ComputeCluster::new("compute".to_string())
1842 .capacity_group(CapacityGroup {
1843 group_id: "batch".to_string(),
1844 instance_type: Some("t4g.medium".to_string()),
1845 profile: None,
1846 min_size: 1,
1847 max_size: 1,
1848 scale_policy: None,
1849 nested_virtualization: None,
1850 })
1851 .build();
1852 stack.resources.insert(
1853 "compute".to_string(),
1854 ResourceEntry {
1855 config: Resource::new(cluster),
1856 lifecycle: ResourceLifecycle::Frozen,
1857 dependencies: Vec::new(),
1858 remote_access: false,
1859 enabled_when: None,
1860 },
1861 );
1862
1863 let plan = plan_compute(&stack, Platform::Aws, None).expect("plan should build");
1864 assert_eq!(plan.pools.len(), 1);
1865 assert_eq!(plan.pools[0].pool_id, "batch");
1866 assert!(plan.pools[0].errors.is_empty());
1867 }
1868
1869 #[test]
1870 fn cloud_plan_recommends_provider_machine_without_mutating_selection() {
1871 let stack = stack_with_container();
1872
1873 let plan = plan_compute(&stack, Platform::Aws, None).expect("plan should build");
1874
1875 let pool = plan.pools.first().expect("general pool should exist");
1876 assert_eq!(pool.pool_id, "general");
1877 assert_eq!(pool.workloads, vec!["api"]);
1878 assert!(pool.selected.machine().is_some());
1879 assert!(pool.machines.iter().any(|machine| machine.recommended));
1880 }
1881
1882 #[test]
1883 fn cloud_plan_only_offers_machines_matching_the_default_image_target() {
1884 let stack = stack_with_container();
1885
1886 for platform in [Platform::Aws, Platform::Gcp, Platform::Azure] {
1887 let plan = plan_compute(&stack, platform, None).expect("plan should build");
1888 let pool = plan.pools.first().expect("general pool should exist");
1889 let expected = instance_catalog::default_architecture(platform)
1890 .expect("managed cloud should have a default architecture");
1891
1892 assert!(pool.machines.iter().all(|machine| {
1893 instance_catalog::find_instance_type(platform, &machine.machine)
1894 .is_some_and(|spec| spec.architecture == expected)
1895 }));
1896 }
1897 }
1898
1899 #[test]
1900 fn selected_machine_is_preserved_as_static_deployment_choice() {
1901 let stack = stack_with_container();
1902 let settings = ComputeSettings {
1903 containers: Default::default(),
1904 pools: [(
1905 "general".to_string(),
1906 ComputePoolSelection::Fixed {
1907 machines: 1,
1908 machine: Some("m7g.xlarge".to_string()),
1909 failure_domains: None,
1910 },
1911 )]
1912 .into_iter()
1913 .collect(),
1914 };
1915
1916 let plan = plan_compute(&stack, Platform::Aws, Some(&settings)).expect("plan should build");
1917
1918 let pool = plan.pools.first().expect("general pool should exist");
1919 assert_eq!(pool.selected.machine(), Some("m7g.xlarge"));
1920 assert!(pool.errors.is_empty());
1921 }
1922
1923 #[test]
1924 fn configurable_cloud_disk_satisfies_storage_above_fixed_local_catalog() {
1925 let mut stack = stack_with_container();
1926 let container = stack
1927 .resources
1928 .get_mut("api")
1929 .and_then(|entry| entry.config.downcast_mut::<Container>())
1930 .expect("test stack should contain a container");
1931 container.ephemeral_storage = Some("8000Gi".to_string());
1932 let settings = ComputeSettings {
1933 containers: Default::default(),
1934 pools: [(
1935 "storage".to_string(),
1936 ComputePoolSelection::Fixed {
1937 machines: 1,
1938 machine: Some("m8i.2xlarge".to_string()),
1939 failure_domains: None,
1940 },
1941 )]
1942 .into_iter()
1943 .collect(),
1944 };
1945
1946 let plan = plan_compute(&stack, Platform::Aws, Some(&settings)).expect("plan should build");
1947 let pool = plan.pools.first().expect("general pool should exist");
1948 assert!(pool.errors.is_empty());
1949 let selected = pool
1950 .machines
1951 .iter()
1952 .find(|option| option.machine == "m8i.2xlarge")
1953 .expect("selected machine should remain an option");
1954 assert_eq!(
1955 selected.profile.ephemeral_storage_bytes,
1956 8_000 * 1024 * 1024 * 1024
1957 );
1958 }
1959
1960 #[test]
1961 fn selected_machine_defines_architecture_when_workloads_do_not() {
1962 let stack = stack_with_container();
1963 let settings = ComputeSettings {
1964 containers: Default::default(),
1965 pools: [(
1966 "general".to_string(),
1967 ComputePoolSelection::Fixed {
1968 machines: 1,
1969 machine: Some("m7i.xlarge".to_string()),
1970 failure_domains: None,
1971 },
1972 )]
1973 .into_iter()
1974 .collect(),
1975 };
1976
1977 let plan = plan_compute(&stack, Platform::Aws, Some(&settings)).expect("plan should build");
1978
1979 let pool = plan.pools.first().expect("general pool should exist");
1980 assert!(pool.errors.is_empty());
1981 assert!(pool.machines.iter().all(|machine| {
1982 instance_catalog::find_instance_type(Platform::Aws, &machine.machine)
1983 .is_some_and(|spec| spec.architecture == Architecture::X86_64)
1984 }));
1985 }
1986
1987 #[test]
1988 fn graviton4_compute_machine_can_be_selected() {
1989 let stack = stack_with_container();
1990 let settings = ComputeSettings {
1991 containers: Default::default(),
1992 pools: [(
1993 "general".to_string(),
1994 ComputePoolSelection::Fixed {
1995 machines: 1,
1996 machine: Some("c8g.2xlarge".to_string()),
1997 failure_domains: None,
1998 },
1999 )]
2000 .into_iter()
2001 .collect(),
2002 };
2003
2004 let plan = plan_compute(&stack, Platform::Aws, Some(&settings)).expect("plan should build");
2005
2006 let pool = plan.pools.first().expect("general pool should exist");
2007 assert!(pool.errors.is_empty(), "{:?}", pool.errors);
2008 let selected = pool
2009 .machines
2010 .iter()
2011 .find(|option| option.machine == "c8g.2xlarge")
2012 .expect("selected machine should be offered");
2013 assert!(selected.recommended);
2014 assert_eq!(selected.profile.architecture, Some(Architecture::Arm64));
2015 }
2016
2017 #[test]
2018 fn explicit_capacity_group_requirements_are_merged_with_workloads() {
2019 let mut stack = stack_with_container();
2020 let cluster = ComputeCluster::new("compute".to_string())
2021 .capacity_group(CapacityGroup {
2022 group_id: "general".to_string(),
2023 instance_type: None,
2024 profile: Some(MachineProfile {
2025 cpu: "4".to_string(),
2026 memory_bytes: 16 * 1024 * 1024 * 1024,
2027 ephemeral_storage_bytes: 20 * 1024 * 1024 * 1024,
2028 architecture: Some(Architecture::X86_64),
2029 gpu: None,
2030 }),
2031 min_size: 2,
2032 max_size: 5,
2033 scale_policy: None,
2034 nested_virtualization: Some(true),
2035 })
2036 .build();
2037 stack.resources.insert(
2038 "compute".to_string(),
2039 ResourceEntry {
2040 config: Resource::new(cluster),
2041 lifecycle: ResourceLifecycle::Frozen,
2042 dependencies: Vec::new(),
2043 remote_access: false,
2044 enabled_when: None,
2045 },
2046 );
2047
2048 let plan = plan_compute(&stack, Platform::Aws, None).expect("plan should build");
2049
2050 let pool = plan.pools.first().expect("general pool should exist");
2051 let machine = pool
2052 .selected
2053 .machine()
2054 .expect("AWS selection should include a machine");
2055 let spec = instance_catalog::find_instance_type(Platform::Aws, machine)
2056 .expect("selected machine should exist in the catalog");
2057 assert!(spec.is_nested_virt_capable());
2058 assert_eq!(pool.selected.min_size(), 2);
2059 assert_eq!(pool.selected.max_size(), 5);
2060 assert!(pool.errors.is_empty());
2061 }
2062
2063 #[test]
2064 fn materialized_machine_profile_is_not_counted_as_workload_demand() {
2065 let mut stack = stack_with_container();
2066 let selected = instance_catalog::find_instance_type(Platform::Aws, "m7g.xlarge")
2067 .expect("machine should exist in catalog");
2068 let cluster = ComputeCluster::new("compute".to_string())
2069 .capacity_group(CapacityGroup {
2070 group_id: "general".to_string(),
2071 instance_type: Some(selected.name.to_string()),
2072 profile: Some(selected.to_machine_profile()),
2073 min_size: 1,
2074 max_size: 1,
2075 scale_policy: None,
2076 nested_virtualization: None,
2077 })
2078 .build();
2079 stack.resources.insert(
2080 "compute".to_string(),
2081 ResourceEntry {
2082 config: Resource::new(cluster),
2083 lifecycle: ResourceLifecycle::Frozen,
2084 dependencies: Vec::new(),
2085 remote_access: false,
2086 enabled_when: None,
2087 },
2088 );
2089
2090 let plan = plan_compute(&stack, Platform::Aws, None).expect("plan should build");
2091 let pool = plan.pools.first().expect("general pool should exist");
2092
2093 assert_eq!(pool.requirements.cpu, "2");
2094 assert_eq!(pool.requirements.memory_bytes, 4 * 1024 * 1024 * 1024);
2095 assert_eq!(pool.recommended.machine(), Some("m7g.xlarge"));
2096 assert!(pool
2097 .machines
2098 .iter()
2099 .any(|option| option.machine == "m7g.xlarge"));
2100 }
2101
2102 #[test]
2103 fn nested_x86_fixed_range_pool_preserves_bounds_and_rejects_graviton() {
2104 let daemon = Daemon::new("vm-runtime-loader".to_string())
2105 .code(DaemonCode::Image {
2106 image: "example.com/vm-runtime:latest".to_string(),
2107 })
2108 .cluster("vm-runtime".to_string())
2109 .cpu(ResourceSpec {
2110 min: "2".to_string(),
2111 desired: "2".to_string(),
2112 })
2113 .memory(ResourceSpec {
2114 min: "4Gi".to_string(),
2115 desired: "4Gi".to_string(),
2116 })
2117 .permissions("loader".to_string())
2118 .build();
2119 let cluster = ComputeCluster::new("vm-runtime".to_string())
2120 .capacity_group(CapacityGroup {
2121 group_id: "general".to_string(),
2122 instance_type: None,
2123 profile: Some(MachineProfile {
2124 cpu: "4".to_string(),
2125 memory_bytes: 16 * 1024 * 1024 * 1024,
2126 ephemeral_storage_bytes: 20 * 1024 * 1024 * 1024,
2127 architecture: Some(Architecture::X86_64),
2128 gpu: None,
2129 }),
2130 min_size: 2,
2131 max_size: 2,
2132 scale_policy: Some(CapacityGroupScalePolicy::Fixed {
2133 machines: ComputeChoiceRange {
2134 min: 1,
2135 max: 5,
2136 default: 2,
2137 },
2138 }),
2139 nested_virtualization: Some(true),
2140 })
2141 .build();
2142 let stack = Stack {
2143 id: "vm-runtime".to_string(),
2144 resources: [
2145 (
2146 "vm-runtime-loader".to_string(),
2147 ResourceEntry {
2148 config: Resource::new(daemon),
2149 lifecycle: ResourceLifecycle::Live,
2150 dependencies: Vec::new(),
2151 remote_access: false,
2152 enabled_when: None,
2153 },
2154 ),
2155 (
2156 "vm-runtime".to_string(),
2157 ResourceEntry {
2158 config: Resource::new(cluster),
2159 lifecycle: ResourceLifecycle::Frozen,
2160 dependencies: Vec::new(),
2161 remote_access: false,
2162 enabled_when: None,
2163 },
2164 ),
2165 ]
2166 .into_iter()
2167 .collect(),
2168 permissions: crate::permissions::PermissionsConfig::default(),
2169 supported_platforms: None,
2170 inputs: vec![],
2171 dynamic_container_repositories: Vec::new(),
2172 dynamic_container_image_resources: Vec::new(),
2173 operations: None,
2174 };
2175
2176 let plan = plan_compute(&stack, Platform::Aws, None).expect("plan should build");
2177 let pool = plan.pools.first().expect("general pool should exist");
2178 assert_eq!(pool.recommended.machine(), Some("m8i.2xlarge"));
2179 assert_eq!(pool.recommended.min_size(), 2);
2180 assert_eq!(pool.recommended.max_size(), 2);
2181 assert_eq!(
2182 pool.scale,
2183 CapacityGroupScalePolicy::Fixed {
2184 machines: ComputeChoiceRange {
2185 min: 1,
2186 max: 5,
2187 default: 2,
2188 },
2189 }
2190 );
2191 assert!(!pool
2192 .machines
2193 .iter()
2194 .any(|option| option.machine == "m7g.2xlarge"));
2195
2196 let invalid_settings = ComputeSettings {
2197 containers: Default::default(),
2198 pools: [(
2199 "general".to_string(),
2200 ComputePoolSelection::Fixed {
2201 machines: 2,
2202 machine: Some("m7g.2xlarge".to_string()),
2203 failure_domains: None,
2204 },
2205 )]
2206 .into_iter()
2207 .collect(),
2208 };
2209 let invalid_plan = plan_compute(&stack, Platform::Aws, Some(&invalid_settings))
2210 .expect("plan should build");
2211 assert!(!invalid_plan.pools[0].errors.is_empty());
2212 }
2213
2214 fn declared_pool_errors(
2215 scale: CapacityGroupScalePolicy,
2216 selection: ComputePoolSelection,
2217 ) -> Vec<String> {
2218 let mut stack = stack_with_container();
2219 let cluster = ComputeCluster::new("compute".to_string())
2220 .capacity_group(CapacityGroup {
2221 group_id: "general".to_string(),
2222 instance_type: None,
2223 profile: None,
2224 min_size: scale.default_min_size(),
2225 max_size: scale.default_max_size(),
2226 scale_policy: Some(scale),
2227 nested_virtualization: None,
2228 })
2229 .build();
2230 stack.resources.insert(
2231 "compute".to_string(),
2232 ResourceEntry {
2233 config: Resource::new(cluster),
2234 lifecycle: ResourceLifecycle::Frozen,
2235 dependencies: Vec::new(),
2236 remote_access: false,
2237 enabled_when: None,
2238 },
2239 );
2240 let settings = ComputeSettings {
2241 containers: Default::default(),
2242 pools: [("general".to_string(), selection)].into_iter().collect(),
2243 };
2244 plan_compute(&stack, Platform::Aws, Some(&settings))
2245 .expect("plan should build")
2246 .pools
2247 .remove(0)
2248 .errors
2249 }
2250
2251 fn range(min: u32, max: u32, default: u32) -> ComputeChoiceRange {
2252 ComputeChoiceRange { min, max, default }
2253 }
2254
2255 #[test]
2256 fn generated_pool_offers_the_recommendation_as_a_default_not_a_bound() {
2257 let mut stack = stack_with_container();
2258 let api = stack
2259 .resources
2260 .get_mut("api")
2261 .and_then(|entry| entry.config.downcast_mut::<Container>())
2262 .expect("test stack should contain a container");
2263 api.autoscaling = Some(crate::ContainerAutoscaling {
2264 min: 1,
2265 desired: 1,
2266 max: 3,
2267 target_cpu_percent: Some(70.0),
2268 target_memory_percent: None,
2269 target_http_in_flight_per_replica: None,
2270 max_http_p95_latency_ms: None,
2271 });
2272
2273 let plan = plan_compute(&stack, Platform::Aws, None).expect("plan should build");
2274 let pool = &plan.pools[0];
2275 assert_eq!(
2276 pool.scale,
2277 CapacityGroupScalePolicy::Autoscale {
2278 min: range(1, 100, 1),
2279 max: range(1, 100, 3),
2280 }
2281 );
2282 assert_eq!(
2283 (pool.recommended.min_size(), pool.recommended.max_size()),
2284 (1, 3)
2285 );
2286
2287 let plan =
2290 plan_compute(&stack_with_container(), Platform::Aws, None).expect("plan should build");
2291 assert_eq!(
2292 plan.pools[0].scale,
2293 CapacityGroupScalePolicy::Autoscale {
2294 min: range(1, 100, 1),
2295 max: range(1, 100, 1),
2296 }
2297 );
2298 assert!(matches!(
2299 plan.pools[0].recommended,
2300 ComputePoolSelection::Fixed { machines: 1, .. }
2301 ));
2302 assert!(plan.pools[0].errors.is_empty());
2303 }
2304
2305 #[test]
2306 fn generated_pool_rejects_a_fleet_too_small_for_desired_replicas() {
2307 let mut stack = stack_with_container();
2308 let api = stack
2309 .resources
2310 .get_mut("api")
2311 .and_then(|entry| entry.config.downcast_mut::<Container>())
2312 .expect("test stack should contain a container");
2313 api.replicas = Some(3);
2315 let selection = |machines| ComputeSettings {
2316 containers: Default::default(),
2317 pools: [(
2318 "general".to_string(),
2319 ComputePoolSelection::Fixed {
2320 machines,
2321 machine: Some("m7g.xlarge".to_string()),
2322 failure_domains: None,
2323 },
2324 )]
2325 .into_iter()
2326 .collect(),
2327 };
2328
2329 let errors = plan_compute(&stack, Platform::Aws, Some(&selection(1)))
2331 .expect("plan should build")
2332 .pools
2333 .remove(0)
2334 .errors;
2335 assert_eq!(errors.len(), 1, "{errors:?}");
2336 assert!(
2337 errors[0].starts_with(
2338 "Pool 'general' is too small for its workloads: 1 x m7g.xlarge has 3.50 vCPU"
2339 ),
2340 "{errors:?}"
2341 );
2342 assert!(errors[0].contains("request 6.00 vCPU"), "{errors:?}");
2343
2344 let errors = plan_compute(&stack, Platform::Aws, Some(&selection(2)))
2345 .expect("plan should build")
2346 .pools
2347 .remove(0)
2348 .errors;
2349 assert!(errors.is_empty(), "{errors:?}");
2350 }
2351
2352 #[test]
2353 fn fixed_and_equal_autoscale_bounds_are_interchangeable_for_declared_pools() {
2354 let fixed = |machines| ComputePoolSelection::Fixed {
2355 machines,
2356 machine: Some("m7g.xlarge".to_string()),
2357 failure_domains: None,
2358 };
2359 let autoscale = |min, max| ComputePoolSelection::Autoscale {
2360 min,
2361 max,
2362 machine: Some("m7g.xlarge".to_string()),
2363 failure_domains: None,
2364 };
2365 let declared_fixed = CapacityGroupScalePolicy::Fixed {
2366 machines: range(2, 4, 2),
2367 };
2368 let declared_autoscale = CapacityGroupScalePolicy::Autoscale {
2369 min: range(1, 3, 1),
2370 max: range(2, 6, 4),
2371 };
2372
2373 assert!(declared_pool_errors(declared_fixed.clone(), autoscale(3, 3)).is_empty());
2374 assert_eq!(
2375 declared_pool_errors(declared_fixed.clone(), autoscale(2, 4)),
2376 vec!["Pool 'general' must use fixed mode with 2-4 machines; autoscale 2-4 is not allowed"]
2377 );
2378 assert_eq!(
2379 declared_pool_errors(declared_fixed, autoscale(5, 5)),
2380 vec!["Pool 'general' machine count 5 is outside the allowed range 2-4"]
2381 );
2382
2383 assert!(declared_pool_errors(declared_autoscale.clone(), fixed(3)).is_empty());
2385 assert_eq!(
2386 declared_pool_errors(declared_autoscale.clone(), fixed(1)),
2387 vec!["Pool 'general' fixed machine count 1 is outside the allowed range 2-3"]
2388 );
2389 assert_eq!(
2390 declared_pool_errors(
2391 CapacityGroupScalePolicy::Autoscale {
2392 min: range(1, 1, 1),
2393 max: range(3, 5, 3),
2394 },
2395 fixed(1),
2396 ),
2397 vec!["Pool 'general' must use autoscale mode (minimum 1-1, maximum 3-5)"]
2398 );
2399 assert_eq!(
2400 declared_pool_errors(declared_autoscale, autoscale(1, 7)),
2401 vec!["Pool 'general' autoscale maximum 7 is outside the allowed range 2-6"]
2402 );
2403 }
2404
2405 #[test]
2406 fn local_plan_has_no_provider_machine_choices() {
2407 let stack = stack_with_container();
2408
2409 let plan = plan_compute(&stack, Platform::Local, None).expect("plan should build");
2410
2411 let pool = plan.pools.first().expect("general pool should exist");
2412 assert_eq!(pool.selected.machine(), None);
2413 assert!(pool.machines.is_empty());
2414 assert!(pool.errors.is_empty());
2415 }
2416}