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 data_disk: None,
975 }
976}
977
978fn merge_scale_policy(
979 existing: &CapacityGroupScalePolicy,
980 declared: &CapacityGroupScalePolicy,
981) -> CapacityGroupScalePolicy {
982 match (existing, declared) {
983 (
984 CapacityGroupScalePolicy::Fixed {
985 machines: existing_machines,
986 },
987 CapacityGroupScalePolicy::Fixed {
988 machines: declared_machines,
989 },
990 ) => CapacityGroupScalePolicy::Fixed {
991 machines: merge_choice_range(existing_machines, declared_machines),
992 },
993 (_, declared) => declared.clone(),
994 }
995}
996
997fn merge_choice_range(
998 existing: &ComputeChoiceRange,
999 declared: &ComputeChoiceRange,
1000) -> ComputeChoiceRange {
1001 ComputeChoiceRange {
1002 min: existing.min.max(declared.min),
1003 max: existing.max.max(declared.max),
1004 default: declared.default,
1005 }
1006}
1007
1008fn validate_selection_against_scale(
1009 selection: &ComputePoolSelection,
1010 scale: &CapacityGroupScalePolicy,
1011) -> std::result::Result<(), String> {
1012 match (selection, scale) {
1015 (
1016 ComputePoolSelection::Fixed { machines, .. },
1017 CapacityGroupScalePolicy::Fixed { machines: allowed },
1018 ) => check_machine_count("fixed machine count", *machines, allowed.min, allowed.max),
1019 (
1020 ComputePoolSelection::Autoscale { min, max, .. },
1021 CapacityGroupScalePolicy::Autoscale {
1022 min: allowed_min,
1023 max: allowed_max,
1024 },
1025 ) => {
1026 check_machine_count("autoscale minimum", *min, allowed_min.min, allowed_min.max)?;
1027 check_machine_count("autoscale maximum", *max, allowed_max.min, allowed_max.max)
1028 }
1029 (
1030 ComputePoolSelection::Fixed { machines, .. },
1031 CapacityGroupScalePolicy::Autoscale {
1032 min: allowed_min,
1033 max: allowed_max,
1034 },
1035 ) => {
1036 let lowest = allowed_min.min.max(allowed_max.min);
1037 let highest = allowed_min.max.min(allowed_max.max);
1038 if lowest > highest {
1039 return Err(format!(
1040 "must use autoscale mode (minimum {}-{}, maximum {}-{})",
1041 allowed_min.min, allowed_min.max, allowed_max.min, allowed_max.max
1042 ));
1043 }
1044 check_machine_count("fixed machine count", *machines, lowest, highest)
1045 }
1046 (
1047 ComputePoolSelection::Autoscale { min, max, .. },
1048 CapacityGroupScalePolicy::Fixed { machines: allowed },
1049 ) => {
1050 if min != max {
1051 return Err(format!(
1052 "must use fixed mode with {}-{} machines; autoscale {min}-{max} is not allowed",
1053 allowed.min, allowed.max
1054 ));
1055 }
1056 check_machine_count("machine count", *min, allowed.min, allowed.max)
1057 }
1058 }
1059}
1060
1061fn check_machine_count(
1062 label: &str,
1063 value: u32,
1064 lowest: u32,
1065 highest: u32,
1066) -> std::result::Result<(), String> {
1067 if (lowest..=highest).contains(&value) {
1068 Ok(())
1069 } else {
1070 Err(format!(
1071 "{label} {value} is outside the allowed range {lowest}-{highest}"
1072 ))
1073 }
1074}
1075
1076fn needed_container_pool(container: &Container) -> &'static str {
1077 if container.stateful && container.persistent_storage.is_some() {
1078 return "stateful";
1079 }
1080 if container.gpu.is_some() {
1081 return "gpu";
1082 }
1083 if let Some(storage) = &container.ephemeral_storage {
1084 if instance_catalog::parse_memory_bytes(storage).unwrap_or(0) > 200 * 1024 * 1024 * 1024 {
1085 return "storage";
1086 }
1087 }
1088 "general"
1089}
1090
1091fn default_min_machines(requirements: &WorkloadRequirements) -> u32 {
1092 if requirements.total_cpu_at_desired > 0.0 || requirements.total_memory_bytes_at_desired > 0 {
1093 1
1094 } else {
1095 0
1096 }
1097}
1098
1099fn default_max_machines(requirements: &WorkloadRequirements) -> u32 {
1100 let min = default_min_machines(requirements);
1101 let by_cpu =
1102 (requirements.total_cpu_at_max / requirements.max_cpu_per_container.max(1.0)).ceil() as u32;
1103 let by_mem = requirements
1104 .total_memory_bytes_at_max
1105 .div_ceil(requirements.max_memory_per_container.max(1)) as u32;
1106 min.max(by_cpu).max(by_mem).max(1)
1107}
1108
1109#[cfg(test)]
1110mod tests {
1111 use super::*;
1112 use crate::{
1113 instance_catalog::Architecture, CapacityGroup, CapacityGroupScalePolicy,
1114 ComputeChoiceRange, ComputeCluster, ComputeSettings, ContainerCode, DaemonCode, Resource,
1115 ResourceEntry, ResourceLifecycle, Stack, Storage, Worker, WorkerCode,
1116 };
1117
1118 #[test]
1119 fn deployment_resources_drive_machine_planning_on_every_cloud() {
1120 let mut stack = stack_with_container();
1121 let container = stack
1122 .resources
1123 .get_mut("api")
1124 .unwrap()
1125 .config
1126 .downcast_mut::<Container>()
1127 .unwrap();
1128 container.resource_choices = Some(crate::ContainerResourceChoices {
1129 cpu: Some(crate::ResourceChoiceRange {
1130 min: "0.5".into(),
1131 max: "8".into(),
1132 default: "2".into(),
1133 }),
1134 memory: Some(crate::ResourceChoiceRange {
1135 min: "512Mi".into(),
1136 max: "16Gi".into(),
1137 default: "4Gi".into(),
1138 }),
1139 });
1140 let settings: ComputeSettings = serde_json::from_value(serde_json::json!({
1141 "pools": { "general": { "mode": "fixed", "machines": 1 } },
1142 "containers": { "api": { "cpu": 8, "memory": "16Gi" } }
1143 }))
1144 .unwrap();
1145 for platform in [Platform::Aws, Platform::Gcp, Platform::Azure] {
1146 let default_plan = plan_compute(&stack, platform, None).unwrap();
1147 let selected_plan = plan_compute(&stack, platform, Some(&settings)).unwrap();
1148 let pool = &selected_plan.pools[0];
1149 assert!(pool.errors.is_empty(), "{platform}: {:?}", pool.errors);
1150 assert_eq!(pool.requirements.cpu, "8");
1151 assert_eq!(pool.requirements.memory_bytes, 16 * 1024 * 1024 * 1024);
1152 assert_ne!(
1153 pool.selected.machine(),
1154 default_plan.pools[0].selected.machine()
1155 );
1156 assert_eq!(selected_plan.containers[0].cpu.min, "8");
1157 assert_eq!(selected_plan.containers[0].memory.desired, "16Gi");
1158 assert_eq!(pool.selected.min_size(), 1);
1159 assert_eq!(pool.selected.max_size(), 1);
1160 }
1161 assert_eq!(
1162 settings.pools["general"].machine(),
1163 None,
1164 "resolving a plan must preserve automatic machine intent"
1165 );
1166 let original = stack.resources["api"]
1167 .config
1168 .downcast_ref::<Container>()
1169 .unwrap();
1170 assert_eq!(
1171 original.cpu.desired, "2",
1172 "planning must not mutate release defaults"
1173 );
1174 }
1175
1176 #[test]
1177 fn explicit_machine_can_fit_a_low_cpu_high_memory_workload() {
1178 let mut stack = stack_with_container();
1179 let container = stack
1180 .resources
1181 .get_mut("api")
1182 .unwrap()
1183 .config
1184 .downcast_mut::<Container>()
1185 .unwrap();
1186 container.cpu = ResourceSpec {
1187 min: "0.5".into(),
1188 desired: "0.5".into(),
1189 };
1190 container.memory = ResourceSpec {
1191 min: "8Gi".into(),
1192 desired: "8Gi".into(),
1193 };
1194 let settings: ComputeSettings = serde_json::from_value(serde_json::json!({
1195 "pools": {"general":{"mode":"fixed","machines":1,"machine":"n2-standard-4"}}
1196 }))
1197 .unwrap();
1198 let plan = plan_compute(&stack, Platform::Gcp, Some(&settings)).unwrap();
1199 assert!(
1200 plan.pools[0].errors.is_empty(),
1201 "{:?}",
1202 plan.pools[0].errors
1203 );
1204 assert_eq!(plan.pools[0].selected.machine(), Some("n2-standard-4"));
1205 }
1206
1207 #[test]
1208 fn pin_must_leave_host_capacity_for_one_replica() {
1209 let mut stack = stack_with_container();
1210 stack
1211 .resources
1212 .get_mut("api")
1213 .unwrap()
1214 .config
1215 .downcast_mut::<Container>()
1216 .unwrap()
1217 .cpu = ResourceSpec {
1218 min: "4".into(),
1219 desired: "4".into(),
1220 };
1221 let settings: ComputeSettings = serde_json::from_value(serde_json::json!({
1222 "pools":{"general":{"mode":"fixed","machines":1,"machine":"t3.xlarge"}}
1223 }))
1224 .unwrap();
1225 let plan = plan_compute(&stack, Platform::Aws, Some(&settings)).unwrap();
1226 assert!(
1227 plan.pools[0]
1228 .errors
1229 .iter()
1230 .any(|error| error.contains("host resources")),
1231 "{:?}",
1232 plan.pools[0].errors
1233 );
1234 }
1235
1236 #[test]
1237 fn valid_pin_does_not_require_automatic_recommendation_headroom() {
1238 let machine = instance_catalog::catalog_for_platform(Platform::Gcp)
1239 .into_iter()
1240 .max_by_key(|machine| machine.memory_bytes)
1241 .unwrap();
1242 let mut stack = stack_with_container();
1243 let container = stack
1244 .resources
1245 .get_mut("api")
1246 .unwrap()
1247 .config
1248 .downcast_mut::<Container>()
1249 .unwrap();
1250 container.cpu = ResourceSpec {
1251 min: "0.5".into(),
1252 desired: "0.5".into(),
1253 };
1254 let memory = instance_catalog::allocatable_memory_bytes(machine).to_string();
1255 container.memory = ResourceSpec {
1256 min: memory.clone(),
1257 desired: memory,
1258 };
1259 assert!(plan_compute(&stack, Platform::Gcp, None).is_err());
1260 let settings: ComputeSettings = serde_json::from_value(serde_json::json!({
1261 "pools":{"general":{"mode":"fixed","machines":1,"machine":machine.name}}
1262 }))
1263 .unwrap();
1264 let plan = plan_compute(&stack, Platform::Gcp, Some(&settings)).unwrap();
1265 assert!(
1266 plan.pools[0].errors.is_empty(),
1267 "{:?}",
1268 plan.pools[0].errors
1269 );
1270 assert_eq!(plan.pools[0].selected.machine(), Some(machine.name));
1271 }
1272
1273 #[test]
1274 fn invalid_deployment_resources_fail_before_capacity_planning() {
1275 let mut stack = stack_with_container();
1276 stack
1277 .resources
1278 .get_mut("api")
1279 .unwrap()
1280 .config
1281 .downcast_mut::<Container>()
1282 .unwrap()
1283 .resource_choices = Some(crate::ContainerResourceChoices {
1284 cpu: Some(crate::ResourceChoiceRange {
1285 min: "0.5".into(),
1286 max: "4".into(),
1287 default: "2".into(),
1288 }),
1289 memory: Some(crate::ResourceChoiceRange {
1290 min: "512Mi".into(),
1291 max: "8Gi".into(),
1292 default: "4Gi".into(),
1293 }),
1294 });
1295 for selection in [
1296 serde_json::json!({"api":{"cpu": 0}}),
1297 serde_json::json!({"api":{"cpu": 5}}),
1298 serde_json::json!({"api":{"memory": "16Gi"}}),
1299 serde_json::json!({"api":{"memory": "NaNGi"}}),
1300 serde_json::json!({"api":{"memory": "-1Gi"}}),
1301 serde_json::json!({"missing":{"cpu": 1}}),
1302 ] {
1303 let settings: ComputeSettings =
1304 serde_json::from_value(serde_json::json!({"containers": selection})).unwrap();
1305 let error = plan_compute(&stack, Platform::Aws, Some(&settings)).unwrap_err();
1306 assert_eq!(error.code, "CONTAINER_RESOURCE_SELECTION_INVALID");
1307 }
1308 }
1309
1310 #[test]
1311 fn deployment_volume_selection_is_applied_without_changing_release_defaults() {
1312 let mut release = stack_with_container();
1313 release
1314 .resources
1315 .get_mut("api")
1316 .unwrap()
1317 .config
1318 .downcast_mut::<Container>()
1319 .unwrap()
1320 .persistent_storage = Some(crate::PersistentStorage {
1321 size: "20Gi".into(),
1322 mount_path: "/data".into(),
1323 backups: crate::VolumeBackups::default(),
1324 encryption_key: None,
1325 });
1326 let settings: ComputeSettings = serde_json::from_value(serde_json::json!({
1327 "containers": {"api": {"volumeSizeGib": 40}}
1328 }))
1329 .unwrap();
1330 let mut deployment = release.clone();
1331 crate::container_resources::resolve_container_resources(&mut deployment, Some(&settings))
1332 .unwrap();
1333 let volume = |stack: &Stack| {
1334 stack.resources["api"]
1335 .config
1336 .downcast_ref::<Container>()
1337 .unwrap()
1338 .persistent_storage
1339 .as_ref()
1340 .unwrap()
1341 .size
1342 .clone()
1343 };
1344 assert_eq!(volume(&deployment), "40Gi");
1345 assert_eq!(volume(&release), "20Gi");
1346 let mut equivalent = release.clone();
1347 equivalent
1348 .resources
1349 .get_mut("api")
1350 .unwrap()
1351 .config
1352 .downcast_mut::<Container>()
1353 .unwrap()
1354 .persistent_storage
1355 .as_mut()
1356 .unwrap()
1357 .size = "1Ti".into();
1358 let same_capacity: ComputeSettings = serde_json::from_value(serde_json::json!({
1359 "containers": {"api": {"volumeSizeGib": 1024}}
1360 }))
1361 .unwrap();
1362 crate::container_resources::resolve_container_resources(
1363 &mut equivalent,
1364 Some(&same_capacity),
1365 )
1366 .unwrap();
1367 assert_eq!(
1368 volume(&equivalent),
1369 "1Ti",
1370 "equivalent selections preserve PVC quantities"
1371 );
1372 crate::container_resources::resolve_container_resources(&mut deployment, Some(&settings))
1373 .unwrap();
1374 assert_eq!(volume(&deployment), "40Gi");
1375 let smaller_initial: ComputeSettings = serde_json::from_value(serde_json::json!({
1376 "containers": {"api": {"volumeSizeGib": 10}}
1377 }))
1378 .unwrap();
1379 crate::container_resources::resolve_container_resources(
1380 &mut release.clone(),
1381 Some(&smaller_initial),
1382 )
1383 .unwrap();
1384 let mut installed = StackState::new(Platform::Aws);
1385 installed.resources.insert(
1386 "api".into(),
1387 crate::StackResourceState::new_pending(
1388 "Container".into(),
1389 deployment.resources["api"].config.clone(),
1390 None,
1391 vec![],
1392 ),
1393 );
1394 let shrinking: ComputeSettings = serde_json::from_value(serde_json::json!({
1395 "containers": {"api": {"volumeSizeGib": 30}}
1396 }))
1397 .unwrap();
1398 let error =
1399 plan_compute_with_state(&release, Platform::Aws, Some(&shrinking), Some(&installed))
1400 .unwrap_err();
1401 assert_eq!(error.code, "CONTAINER_RESOURCE_SELECTION_INVALID");
1402 assert!(error.to_string().contains("cannot shrink from 40 to 30"));
1403 let imported: ComputeSettings = serde_json::from_value(serde_json::json!({
1404 "containers": {"api": {"volumeSizeGib": "40"}}
1405 }))
1406 .unwrap();
1407 assert_eq!(imported.containers["api"].volume_size_gib, Some(40));
1408 for value in [
1409 serde_json::json!(-1),
1410 serde_json::json!(1.5),
1411 serde_json::json!("4294967296"),
1412 ] {
1413 assert!(
1414 serde_json::from_value::<ComputeSettings>(serde_json::json!({
1415 "containers": {"api": {"volumeSizeGib": value}}
1416 }))
1417 .is_err()
1418 );
1419 }
1420 for (platform, size) in [(Platform::Gcp, 9), (Platform::Azure, 32768)] {
1421 let invalid: ComputeSettings = serde_json::from_value(serde_json::json!({
1422 "containers": {"api": {"volumeSizeGib": size}}
1423 }))
1424 .unwrap();
1425 let error = plan_compute(&release, platform, Some(&invalid)).unwrap_err();
1426 assert_eq!(error.code, "CONTAINER_RESOURCE_SELECTION_INVALID");
1427 assert!(error.to_string().contains("must be between"));
1428 }
1429 for size in [0, 65537] {
1430 let invalid: ComputeSettings = serde_json::from_value(serde_json::json!({
1431 "containers": {"api": {"volumeSizeGib": size}}
1432 }))
1433 .unwrap();
1434 assert_eq!(
1435 crate::container_resources::resolve_container_resources(
1436 &mut release.clone(),
1437 Some(&invalid)
1438 )
1439 .unwrap_err()
1440 .code,
1441 "CONTAINER_RESOURCE_SELECTION_INVALID"
1442 );
1443 }
1444 assert_eq!(
1445 crate::container_resources::resolve_container_resources(
1446 &mut stack_with_container(),
1447 Some(&settings)
1448 )
1449 .unwrap_err()
1450 .code,
1451 "CONTAINER_RESOURCE_SELECTION_INVALID"
1452 );
1453 }
1454
1455 fn stack_with_volume(size: &str) -> Stack {
1456 let mut stack = stack_with_container();
1457 stack
1458 .resources
1459 .get_mut("api")
1460 .unwrap()
1461 .config
1462 .downcast_mut::<Container>()
1463 .unwrap()
1464 .persistent_storage = Some(crate::PersistentStorage {
1465 size: size.into(),
1466 mount_path: "/data".into(),
1467 backups: crate::VolumeBackups::default(),
1468 encryption_key: None,
1469 });
1470 stack
1471 }
1472
1473 fn installed_with_volume(platform: Platform, size: &str) -> StackState {
1474 let mut installed = StackState::new(platform);
1475 installed.resources.insert(
1476 "api".into(),
1477 crate::StackResourceState::new_pending(
1478 "Container".into(),
1479 stack_with_volume(size).resources["api"].config.clone(),
1480 None,
1481 vec![],
1482 ),
1483 );
1484 installed
1485 }
1486
1487 fn api_volume(plan: &ComputePlan) -> (Option<u32>, Option<u32>) {
1488 let api = plan
1489 .containers
1490 .iter()
1491 .find(|container| container.container_id == "api")
1492 .expect("api is planned");
1493 (api.volume_size_gib, api.minimum_volume_size_gib)
1494 }
1495
1496 #[test]
1500 fn a_lower_release_default_keeps_installed_volumes_without_a_selection() {
1501 for platform in [Platform::Aws, Platform::Gcp, Platform::Azure] {
1502 let installed = installed_with_volume(platform, "30Gi");
1503 let plan = plan_compute_with_state(
1504 &stack_with_volume("20Gi"),
1505 platform,
1506 None,
1507 Some(&installed),
1508 )
1509 .expect("a lower release default keeps the installed volume");
1510 assert_eq!(api_volume(&plan), (Some(30), Some(30)), "{platform}");
1511
1512 let plan = plan_compute_with_state(
1513 &stack_with_volume("40Gi"),
1514 platform,
1515 None,
1516 Some(&installed),
1517 )
1518 .expect("a larger release default grows the volume");
1519 assert_eq!(api_volume(&plan), (Some(40), Some(30)), "{platform}");
1520
1521 let shrinking: ComputeSettings = serde_json::from_value(serde_json::json!({
1523 "containers": {"api": {"volumeSizeGib": 25}}
1524 }))
1525 .unwrap();
1526 let error = plan_compute_with_state(
1527 &stack_with_volume("20Gi"),
1528 platform,
1529 Some(&shrinking),
1530 Some(&installed),
1531 )
1532 .unwrap_err();
1533 assert!(
1534 error.to_string().contains("cannot shrink from 30 to 25"),
1535 "{error}"
1536 );
1537 }
1538 }
1539
1540 #[test]
1544 fn setup_source_volume_sizes_below_installed_keep_the_installed_size() {
1545 let mut installed = installed_with_volume(Platform::Aws, "30Gi");
1546 let mut worker = stack_with_volume("10Gi").resources["api"].config.clone();
1547 worker
1548 .downcast_mut::<Container>()
1549 .unwrap()
1550 .persistent_storage = None;
1551 installed.resources.insert(
1552 "worker".into(),
1553 crate::StackResourceState::new_pending("Container".into(), worker, None, vec![]),
1554 );
1555 let mut settings: ComputeSettings = serde_json::from_value(serde_json::json!({
1556 "containers": {
1557 "api": {"volumeSizeGib": 25, "cpu": 2},
1558 "worker": {"volumeSizeGib": 5},
1559 "new": {"volumeSizeGib": 8}
1560 }
1561 }))
1562 .unwrap();
1563
1564 let kept = crate::container_resources::keep_volume_sizes_over_setup_source(
1565 &mut settings,
1566 Some(&installed),
1567 None,
1568 );
1569
1570 assert_eq!(
1571 kept,
1572 vec![crate::container_resources::KeptVolumeSize {
1573 container_id: "api".into(),
1574 requested_gib: 25,
1575 kept_gib: 30,
1576 }]
1577 );
1578 assert_eq!(settings.containers["api"].volume_size_gib, Some(30));
1579 assert_eq!(
1580 settings.containers["api"].cpu,
1581 Some(serde_json::Number::from(2))
1582 );
1583 assert_eq!(settings.containers["worker"].volume_size_gib, Some(5));
1584 assert_eq!(settings.containers["new"].volume_size_gib, Some(8));
1585 let plan = plan_compute_with_state(
1586 &stack_with_volume("20Gi"),
1587 Platform::Aws,
1588 Some(&ComputeSettings {
1589 containers: [("api".to_string(), settings.containers["api"].clone())].into(),
1590 ..Default::default()
1591 }),
1592 Some(&installed_with_volume(Platform::Aws, "30Gi")),
1593 )
1594 .expect("the kept size plans");
1595 assert_eq!(api_volume(&plan), (Some(30), Some(30)));
1596
1597 let mut growing: ComputeSettings = serde_json::from_value(serde_json::json!({
1598 "containers": {"api": {"volumeSizeGib": 35}}
1599 }))
1600 .unwrap();
1601 assert!(
1602 crate::container_resources::keep_volume_sizes_over_setup_source(
1603 &mut growing,
1604 Some(&installed),
1605 None,
1606 )
1607 .is_empty()
1608 );
1609 assert_eq!(growing.containers["api"].volume_size_gib, Some(35));
1610
1611 let chosen: ComputeSettings = serde_json::from_value(serde_json::json!({
1613 "containers": {"api": {"volumeSizeGib": 40}}
1614 }))
1615 .unwrap();
1616 let mut stale: ComputeSettings = serde_json::from_value(serde_json::json!({
1617 "containers": {"api": {"volumeSizeGib": 25}}
1618 }))
1619 .unwrap();
1620 let kept = crate::container_resources::keep_volume_sizes_over_setup_source(
1621 &mut stale,
1622 Some(&installed),
1623 Some(&chosen),
1624 );
1625 assert_eq!(kept[0].kept_gib, 40);
1626 assert_eq!(stale.containers["api"].volume_size_gib, Some(40));
1627
1628 let mut correction: ComputeSettings = serde_json::from_value(serde_json::json!({
1631 "containers": {"api": {"volumeSizeGib": 20}}
1632 }))
1633 .unwrap();
1634 for nothing_installed in [None, Some(&StackState::new(Platform::Aws))] {
1635 assert!(
1636 crate::container_resources::keep_volume_sizes_over_setup_source(
1637 &mut correction,
1638 nothing_installed,
1639 Some(&chosen),
1640 )
1641 .is_empty()
1642 );
1643 assert_eq!(correction.containers["api"].volume_size_gib, Some(20));
1644 }
1645 }
1646
1647 #[test]
1648 fn installed_storage_cannot_be_removed_or_remounted_on_any_provider() {
1649 for platform in [
1650 Platform::Aws,
1651 Platform::Gcp,
1652 Platform::Azure,
1653 Platform::Kubernetes,
1654 ] {
1655 let mut release = stack_with_container();
1656 release
1657 .resources
1658 .get_mut("api")
1659 .unwrap()
1660 .config
1661 .downcast_mut::<Container>()
1662 .unwrap()
1663 .persistent_storage = Some(crate::PersistentStorage {
1664 size: "20Gi".into(),
1665 mount_path: "/data".into(),
1666 backups: crate::VolumeBackups::default(),
1667 encryption_key: None,
1668 });
1669 let mut installed = StackState::new(platform);
1670 installed.resources.insert(
1671 "api".into(),
1672 crate::StackResourceState::new_pending(
1673 "Container".into(),
1674 release.resources["api"].config.clone(),
1675 None,
1676 vec![],
1677 ),
1678 );
1679 for remove in [false, true] {
1680 let mut desired = release.clone();
1681 let storage = &mut desired
1682 .resources
1683 .get_mut("api")
1684 .unwrap()
1685 .config
1686 .downcast_mut::<Container>()
1687 .unwrap()
1688 .persistent_storage;
1689 if remove {
1690 *storage = None;
1691 } else {
1692 storage.as_mut().unwrap().mount_path = "/other".into();
1693 }
1694 let error = plan_compute_with_state(&desired, platform, None, Some(&installed))
1695 .unwrap_err();
1696 assert_eq!(error.code, "CONTAINER_RESOURCE_SELECTION_INVALID");
1697 assert!(
1698 error.to_string().contains(if remove {
1699 "removing installed"
1700 } else {
1701 "mount path"
1702 }),
1703 "{error}"
1704 );
1705 }
1706 }
1707 }
1708
1709 #[test]
1710 fn release_volume_defaults_obey_provider_bounds_without_a_selection() {
1711 for (platform, size) in [(Platform::Gcp, "5Gi"), (Platform::Azure, "40000Gi")] {
1712 let mut stack = stack_with_container();
1713 stack
1714 .resources
1715 .get_mut("api")
1716 .unwrap()
1717 .config
1718 .downcast_mut::<Container>()
1719 .unwrap()
1720 .persistent_storage = Some(crate::PersistentStorage {
1721 size: size.into(),
1722 mount_path: "/data".into(),
1723 backups: crate::VolumeBackups::default(),
1724 encryption_key: None,
1725 });
1726 let error = plan_compute(&stack, platform, None).unwrap_err();
1727 assert_eq!(error.code, "CONTAINER_RESOURCE_SELECTION_INVALID");
1728 assert!(error.to_string().contains("must be between"));
1729 }
1730 }
1731
1732 #[test]
1733 fn volume_capacity_parsing_rejects_overflow_and_preserves_legacy_units() {
1734 use crate::container_resources::parse_volume_size_gib;
1735 for (value, size) in [
1736 ("20Gi", 20),
1737 ("20GiB", 20),
1738 ("20GB", 20),
1739 ("1Ti", 1024),
1740 ("1TB", 1000),
1741 ("1.5Ti", 1536),
1742 ("1536Mi", 2),
1743 ("0.5Gi", 1),
1744 ("1073741824", 1),
1745 ] {
1746 assert_eq!(parse_volume_size_gib(value).unwrap(), size);
1747 }
1748 for value in ["0Gi", "-1Gi", "NaNGi", "4294967295Ti", "65537Gi"] {
1749 assert!(parse_volume_size_gib(value).is_err(), "{value}");
1750 }
1751 }
1752
1753 fn stack_with_container() -> Stack {
1754 let container = Container::new("api".to_string())
1755 .code(ContainerCode::Image {
1756 image: "api:latest".to_string(),
1757 })
1758 .cpu(ResourceSpec {
1759 min: "1".to_string(),
1760 desired: "2".to_string(),
1761 })
1762 .memory(ResourceSpec {
1763 min: "2Gi".to_string(),
1764 desired: "4Gi".to_string(),
1765 })
1766 .permissions("api".to_string())
1767 .build();
1768 Stack {
1769 id: "test".to_string(),
1770 resources: [(
1771 "api".to_string(),
1772 ResourceEntry {
1773 config: Resource::new(container),
1774 lifecycle: ResourceLifecycle::Live,
1775 dependencies: Vec::new(),
1776 display_name: None,
1777 description: None,
1778 remote_access: false,
1779 enabled_when: None,
1780 },
1781 )]
1782 .into_iter()
1783 .collect(),
1784 permissions: crate::permissions::PermissionsConfig::default(),
1785 supported_platforms: None,
1786 inputs: vec![],
1787 dynamic_container_repositories: Vec::new(),
1788 dynamic_container_image_resources: Vec::new(),
1789 operations: None,
1790 }
1791 }
1792
1793 #[test]
1794 fn serverless_workers_and_storage_do_not_allocate_machine_pools() {
1795 let mut stack = stack_with_container();
1796 stack.resources.clear();
1797 for (id, resource) in [
1798 (
1799 "api",
1800 Resource::new(
1801 Worker::new("api".to_string())
1802 .code(WorkerCode::Image {
1803 image: "example/worker:v1".to_string(),
1804 })
1805 .permissions("execution".to_string())
1806 .build(),
1807 ),
1808 ),
1809 (
1810 "data",
1811 Resource::new(Storage::new("data".to_string()).build()),
1812 ),
1813 ] {
1814 stack.resources.insert(
1815 id.to_string(),
1816 ResourceEntry {
1817 config: resource,
1818 lifecycle: ResourceLifecycle::Frozen,
1819 dependencies: Vec::new(),
1820 display_name: None,
1821 description: None,
1822 remote_access: false,
1823 enabled_when: None,
1824 },
1825 );
1826 }
1827
1828 for platform in [Platform::Aws, Platform::Gcp, Platform::Azure] {
1829 let plan = plan_compute(&stack, platform, None).expect("plan should build");
1830 assert!(plan.pools.is_empty(), "serverless plan allocated {plan:?}");
1831 }
1832 }
1833
1834 #[test]
1835 fn empty_stack_does_not_allocate_machine_pools() {
1836 let mut stack = stack_with_container();
1837 stack.resources.clear();
1838 let plan = plan_compute(&stack, Platform::Aws, None).expect("plan should build");
1839 assert!(plan.pools.is_empty());
1840 }
1841
1842 #[test]
1843 fn explicit_cluster_without_workloads_only_plans_declared_pools() {
1844 let mut stack = stack_with_container();
1845 stack.resources.clear();
1846 let cluster = ComputeCluster::new("compute".to_string())
1847 .capacity_group(CapacityGroup {
1848 group_id: "batch".to_string(),
1849 instance_type: Some("t4g.medium".to_string()),
1850 profile: None,
1851 min_size: 1,
1852 max_size: 1,
1853 scale_policy: None,
1854 nested_virtualization: None,
1855 })
1856 .build();
1857 stack.resources.insert(
1858 "compute".to_string(),
1859 ResourceEntry {
1860 config: Resource::new(cluster),
1861 lifecycle: ResourceLifecycle::Frozen,
1862 dependencies: Vec::new(),
1863 display_name: None,
1864 description: None,
1865 remote_access: false,
1866 enabled_when: None,
1867 },
1868 );
1869
1870 let plan = plan_compute(&stack, Platform::Aws, None).expect("plan should build");
1871 assert_eq!(plan.pools.len(), 1);
1872 assert_eq!(plan.pools[0].pool_id, "batch");
1873 assert!(plan.pools[0].errors.is_empty());
1874 }
1875
1876 #[test]
1877 fn cloud_plan_recommends_provider_machine_without_mutating_selection() {
1878 let stack = stack_with_container();
1879
1880 let plan = plan_compute(&stack, Platform::Aws, None).expect("plan should build");
1881
1882 let pool = plan.pools.first().expect("general pool should exist");
1883 assert_eq!(pool.pool_id, "general");
1884 assert_eq!(pool.workloads, vec!["api"]);
1885 assert!(pool.selected.machine().is_some());
1886 assert!(pool.machines.iter().any(|machine| machine.recommended));
1887 }
1888
1889 #[test]
1890 fn cloud_plan_only_offers_machines_matching_the_default_image_target() {
1891 let stack = stack_with_container();
1892
1893 for platform in [Platform::Aws, Platform::Gcp, Platform::Azure] {
1894 let plan = plan_compute(&stack, platform, None).expect("plan should build");
1895 let pool = plan.pools.first().expect("general pool should exist");
1896 let expected = instance_catalog::default_architecture(platform)
1897 .expect("managed cloud should have a default architecture");
1898
1899 assert!(pool.machines.iter().all(|machine| {
1900 instance_catalog::find_instance_type(platform, &machine.machine)
1901 .is_some_and(|spec| spec.architecture == expected)
1902 }));
1903 }
1904 }
1905
1906 #[test]
1907 fn selected_machine_is_preserved_as_static_deployment_choice() {
1908 let stack = stack_with_container();
1909 let settings = ComputeSettings {
1910 containers: Default::default(),
1911 pools: [(
1912 "general".to_string(),
1913 ComputePoolSelection::Fixed {
1914 machines: 1,
1915 machine: Some("m7g.xlarge".to_string()),
1916 failure_domains: None,
1917 },
1918 )]
1919 .into_iter()
1920 .collect(),
1921 };
1922
1923 let plan = plan_compute(&stack, Platform::Aws, Some(&settings)).expect("plan should build");
1924
1925 let pool = plan.pools.first().expect("general pool should exist");
1926 assert_eq!(pool.selected.machine(), Some("m7g.xlarge"));
1927 assert!(pool.errors.is_empty());
1928 }
1929
1930 #[test]
1931 fn configurable_cloud_disk_satisfies_storage_above_fixed_local_catalog() {
1932 let mut stack = stack_with_container();
1933 let container = stack
1934 .resources
1935 .get_mut("api")
1936 .and_then(|entry| entry.config.downcast_mut::<Container>())
1937 .expect("test stack should contain a container");
1938 container.ephemeral_storage = Some("8000Gi".to_string());
1939 let settings = ComputeSettings {
1940 containers: Default::default(),
1941 pools: [(
1942 "storage".to_string(),
1943 ComputePoolSelection::Fixed {
1944 machines: 1,
1945 machine: Some("m8i.2xlarge".to_string()),
1946 failure_domains: None,
1947 },
1948 )]
1949 .into_iter()
1950 .collect(),
1951 };
1952
1953 let plan = plan_compute(&stack, Platform::Aws, Some(&settings)).expect("plan should build");
1954 let pool = plan.pools.first().expect("general pool should exist");
1955 assert!(pool.errors.is_empty());
1956 let selected = pool
1957 .machines
1958 .iter()
1959 .find(|option| option.machine == "m8i.2xlarge")
1960 .expect("selected machine should remain an option");
1961 assert_eq!(
1962 selected.profile.ephemeral_storage_bytes,
1963 8_000 * 1024 * 1024 * 1024
1964 );
1965 }
1966
1967 #[test]
1968 fn selected_machine_defines_architecture_when_workloads_do_not() {
1969 let stack = stack_with_container();
1970 let settings = ComputeSettings {
1971 containers: Default::default(),
1972 pools: [(
1973 "general".to_string(),
1974 ComputePoolSelection::Fixed {
1975 machines: 1,
1976 machine: Some("m7i.xlarge".to_string()),
1977 failure_domains: None,
1978 },
1979 )]
1980 .into_iter()
1981 .collect(),
1982 };
1983
1984 let plan = plan_compute(&stack, Platform::Aws, Some(&settings)).expect("plan should build");
1985
1986 let pool = plan.pools.first().expect("general pool should exist");
1987 assert!(pool.errors.is_empty());
1988 assert!(pool.machines.iter().all(|machine| {
1989 instance_catalog::find_instance_type(Platform::Aws, &machine.machine)
1990 .is_some_and(|spec| spec.architecture == Architecture::X86_64)
1991 }));
1992 }
1993
1994 #[test]
1995 fn graviton4_compute_machine_can_be_selected() {
1996 let stack = stack_with_container();
1997 let settings = ComputeSettings {
1998 containers: Default::default(),
1999 pools: [(
2000 "general".to_string(),
2001 ComputePoolSelection::Fixed {
2002 machines: 1,
2003 machine: Some("c8g.2xlarge".to_string()),
2004 failure_domains: None,
2005 },
2006 )]
2007 .into_iter()
2008 .collect(),
2009 };
2010
2011 let plan = plan_compute(&stack, Platform::Aws, Some(&settings)).expect("plan should build");
2012
2013 let pool = plan.pools.first().expect("general pool should exist");
2014 assert!(pool.errors.is_empty(), "{:?}", pool.errors);
2015 let selected = pool
2016 .machines
2017 .iter()
2018 .find(|option| option.machine == "c8g.2xlarge")
2019 .expect("selected machine should be offered");
2020 assert!(selected.recommended);
2021 assert_eq!(selected.profile.architecture, Some(Architecture::Arm64));
2022 }
2023
2024 #[test]
2025 fn explicit_capacity_group_requirements_are_merged_with_workloads() {
2026 let mut stack = stack_with_container();
2027 let cluster = ComputeCluster::new("compute".to_string())
2028 .capacity_group(CapacityGroup {
2029 group_id: "general".to_string(),
2030 instance_type: None,
2031 profile: Some(MachineProfile {
2032 cpu: "4".to_string(),
2033 memory_bytes: 16 * 1024 * 1024 * 1024,
2034 ephemeral_storage_bytes: 20 * 1024 * 1024 * 1024,
2035 architecture: Some(Architecture::X86_64),
2036 gpu: None,
2037 data_disk: None,
2038 }),
2039 min_size: 2,
2040 max_size: 5,
2041 scale_policy: None,
2042 nested_virtualization: Some(true),
2043 })
2044 .build();
2045 stack.resources.insert(
2046 "compute".to_string(),
2047 ResourceEntry {
2048 config: Resource::new(cluster),
2049 lifecycle: ResourceLifecycle::Frozen,
2050 dependencies: Vec::new(),
2051 display_name: None,
2052 description: None,
2053 remote_access: false,
2054 enabled_when: None,
2055 },
2056 );
2057
2058 let plan = plan_compute(&stack, Platform::Aws, None).expect("plan should build");
2059
2060 let pool = plan.pools.first().expect("general pool should exist");
2061 let machine = pool
2062 .selected
2063 .machine()
2064 .expect("AWS selection should include a machine");
2065 let spec = instance_catalog::find_instance_type(Platform::Aws, machine)
2066 .expect("selected machine should exist in the catalog");
2067 assert!(spec.is_nested_virt_capable());
2068 assert_eq!(pool.selected.min_size(), 2);
2069 assert_eq!(pool.selected.max_size(), 5);
2070 assert!(pool.errors.is_empty());
2071 }
2072
2073 #[test]
2077 fn nested_pool_without_architecture_plans_an_aws_machine() {
2078 let mut stack = stack_with_container();
2079 let cluster = ComputeCluster::new("compute".to_string())
2080 .capacity_group(CapacityGroup {
2081 group_id: "general".to_string(),
2082 instance_type: None,
2083 profile: Some(MachineProfile {
2084 cpu: "4".to_string(),
2085 memory_bytes: 16 * 1024 * 1024 * 1024,
2086 ephemeral_storage_bytes: 20 * 1024 * 1024 * 1024,
2087 architecture: None,
2088 gpu: None,
2089 data_disk: None,
2090 }),
2091 min_size: 1,
2092 max_size: 1,
2093 scale_policy: None,
2094 nested_virtualization: Some(true),
2095 })
2096 .build();
2097 stack.resources.insert(
2098 "compute".to_string(),
2099 ResourceEntry {
2100 config: Resource::new(cluster),
2101 lifecycle: ResourceLifecycle::Frozen,
2102 dependencies: Vec::new(),
2103 display_name: None,
2104 description: None,
2105 remote_access: false,
2106 enabled_when: None,
2107 },
2108 );
2109
2110 let plan = plan_compute(&stack, Platform::Aws, None).expect("plan should build");
2111
2112 let pool = plan.pools.first().expect("general pool should exist");
2113 assert!(pool.errors.is_empty(), "{:?}", pool.errors);
2114 let machine = pool
2115 .selected
2116 .machine()
2117 .expect("AWS selection should include a machine");
2118 let spec = instance_catalog::find_instance_type(Platform::Aws, machine)
2119 .expect("selected machine should exist in the catalog");
2120 assert!(spec.is_nested_virt_capable(), "{machine} is not nested-capable");
2121 assert_eq!(spec.architecture, Architecture::X86_64);
2122 assert!(!pool.machines.is_empty());
2123 assert!(pool.machines.iter().all(|option| {
2124 instance_catalog::find_instance_type(Platform::Aws, &option.machine).is_some_and(
2125 |spec| spec.is_nested_virt_capable() && spec.architecture == Architecture::X86_64,
2126 )
2127 }));
2128 }
2129
2130 #[test]
2131 fn materialized_machine_profile_is_not_counted_as_workload_demand() {
2132 let mut stack = stack_with_container();
2133 let selected = instance_catalog::find_instance_type(Platform::Aws, "m7g.xlarge")
2134 .expect("machine should exist in catalog");
2135 let cluster = ComputeCluster::new("compute".to_string())
2136 .capacity_group(CapacityGroup {
2137 group_id: "general".to_string(),
2138 instance_type: Some(selected.name.to_string()),
2139 profile: Some(selected.to_machine_profile()),
2140 min_size: 1,
2141 max_size: 1,
2142 scale_policy: None,
2143 nested_virtualization: None,
2144 })
2145 .build();
2146 stack.resources.insert(
2147 "compute".to_string(),
2148 ResourceEntry {
2149 config: Resource::new(cluster),
2150 lifecycle: ResourceLifecycle::Frozen,
2151 dependencies: Vec::new(),
2152 display_name: None,
2153 description: None,
2154 remote_access: false,
2155 enabled_when: None,
2156 },
2157 );
2158
2159 let plan = plan_compute(&stack, Platform::Aws, None).expect("plan should build");
2160 let pool = plan.pools.first().expect("general pool should exist");
2161
2162 assert_eq!(pool.requirements.cpu, "2");
2163 assert_eq!(pool.requirements.memory_bytes, 4 * 1024 * 1024 * 1024);
2164 assert_eq!(pool.recommended.machine(), Some("m7g.xlarge"));
2165 assert!(pool
2166 .machines
2167 .iter()
2168 .any(|option| option.machine == "m7g.xlarge"));
2169 }
2170
2171 #[test]
2172 fn nested_x86_fixed_range_pool_preserves_bounds_and_rejects_graviton() {
2173 let daemon = Daemon::new("vm-runtime-loader".to_string())
2174 .code(DaemonCode::Image {
2175 image: "example.com/vm-runtime:latest".to_string(),
2176 })
2177 .cluster("vm-runtime".to_string())
2178 .cpu(ResourceSpec {
2179 min: "2".to_string(),
2180 desired: "2".to_string(),
2181 })
2182 .memory(ResourceSpec {
2183 min: "4Gi".to_string(),
2184 desired: "4Gi".to_string(),
2185 })
2186 .permissions("loader".to_string())
2187 .build();
2188 let cluster = ComputeCluster::new("vm-runtime".to_string())
2189 .capacity_group(CapacityGroup {
2190 group_id: "general".to_string(),
2191 instance_type: None,
2192 profile: Some(MachineProfile {
2193 cpu: "4".to_string(),
2194 memory_bytes: 16 * 1024 * 1024 * 1024,
2195 ephemeral_storage_bytes: 20 * 1024 * 1024 * 1024,
2196 architecture: Some(Architecture::X86_64),
2197 gpu: None,
2198 data_disk: None,
2199 }),
2200 min_size: 2,
2201 max_size: 2,
2202 scale_policy: Some(CapacityGroupScalePolicy::Fixed {
2203 machines: ComputeChoiceRange {
2204 min: 1,
2205 max: 5,
2206 default: 2,
2207 },
2208 }),
2209 nested_virtualization: Some(true),
2210 })
2211 .build();
2212 let stack = Stack {
2213 id: "vm-runtime".to_string(),
2214 resources: [
2215 (
2216 "vm-runtime-loader".to_string(),
2217 ResourceEntry {
2218 config: Resource::new(daemon),
2219 lifecycle: ResourceLifecycle::Live,
2220 dependencies: Vec::new(),
2221 display_name: None,
2222 description: None,
2223 remote_access: false,
2224 enabled_when: None,
2225 },
2226 ),
2227 (
2228 "vm-runtime".to_string(),
2229 ResourceEntry {
2230 config: Resource::new(cluster),
2231 lifecycle: ResourceLifecycle::Frozen,
2232 dependencies: Vec::new(),
2233 display_name: None,
2234 description: None,
2235 remote_access: false,
2236 enabled_when: None,
2237 },
2238 ),
2239 ]
2240 .into_iter()
2241 .collect(),
2242 permissions: crate::permissions::PermissionsConfig::default(),
2243 supported_platforms: None,
2244 inputs: vec![],
2245 dynamic_container_repositories: Vec::new(),
2246 dynamic_container_image_resources: Vec::new(),
2247 operations: None,
2248 };
2249
2250 let plan = plan_compute(&stack, Platform::Aws, None).expect("plan should build");
2251 let pool = plan.pools.first().expect("general pool should exist");
2252 assert_eq!(pool.recommended.machine(), Some("m8i.2xlarge"));
2253 assert_eq!(pool.recommended.min_size(), 2);
2254 assert_eq!(pool.recommended.max_size(), 2);
2255 assert_eq!(
2256 pool.scale,
2257 CapacityGroupScalePolicy::Fixed {
2258 machines: ComputeChoiceRange {
2259 min: 1,
2260 max: 5,
2261 default: 2,
2262 },
2263 }
2264 );
2265 assert!(!pool
2266 .machines
2267 .iter()
2268 .any(|option| option.machine == "m7g.2xlarge"));
2269
2270 let invalid_settings = ComputeSettings {
2271 containers: Default::default(),
2272 pools: [(
2273 "general".to_string(),
2274 ComputePoolSelection::Fixed {
2275 machines: 2,
2276 machine: Some("m7g.2xlarge".to_string()),
2277 failure_domains: None,
2278 },
2279 )]
2280 .into_iter()
2281 .collect(),
2282 };
2283 let invalid_plan = plan_compute(&stack, Platform::Aws, Some(&invalid_settings))
2284 .expect("plan should build");
2285 assert!(!invalid_plan.pools[0].errors.is_empty());
2286 }
2287
2288 fn declared_pool_errors(
2289 scale: CapacityGroupScalePolicy,
2290 selection: ComputePoolSelection,
2291 ) -> Vec<String> {
2292 let mut stack = stack_with_container();
2293 let cluster = ComputeCluster::new("compute".to_string())
2294 .capacity_group(CapacityGroup {
2295 group_id: "general".to_string(),
2296 instance_type: None,
2297 profile: None,
2298 min_size: scale.default_min_size(),
2299 max_size: scale.default_max_size(),
2300 scale_policy: Some(scale),
2301 nested_virtualization: None,
2302 })
2303 .build();
2304 stack.resources.insert(
2305 "compute".to_string(),
2306 ResourceEntry {
2307 config: Resource::new(cluster),
2308 lifecycle: ResourceLifecycle::Frozen,
2309 dependencies: Vec::new(),
2310 display_name: None,
2311 description: None,
2312 remote_access: false,
2313 enabled_when: None,
2314 },
2315 );
2316 let settings = ComputeSettings {
2317 containers: Default::default(),
2318 pools: [("general".to_string(), selection)].into_iter().collect(),
2319 };
2320 plan_compute(&stack, Platform::Aws, Some(&settings))
2321 .expect("plan should build")
2322 .pools
2323 .remove(0)
2324 .errors
2325 }
2326
2327 fn range(min: u32, max: u32, default: u32) -> ComputeChoiceRange {
2328 ComputeChoiceRange { min, max, default }
2329 }
2330
2331 #[test]
2332 fn generated_pool_offers_the_recommendation_as_a_default_not_a_bound() {
2333 let mut stack = stack_with_container();
2334 let api = stack
2335 .resources
2336 .get_mut("api")
2337 .and_then(|entry| entry.config.downcast_mut::<Container>())
2338 .expect("test stack should contain a container");
2339 api.autoscaling = Some(crate::ContainerAutoscaling {
2340 min: 1,
2341 desired: 1,
2342 max: 3,
2343 target_cpu_percent: Some(70.0),
2344 target_memory_percent: None,
2345 target_http_in_flight_per_replica: None,
2346 max_http_p95_latency_ms: None,
2347 });
2348
2349 let plan = plan_compute(&stack, Platform::Aws, None).expect("plan should build");
2350 let pool = &plan.pools[0];
2351 assert_eq!(
2352 pool.scale,
2353 CapacityGroupScalePolicy::Autoscale {
2354 min: range(1, 100, 1),
2355 max: range(1, 100, 3),
2356 }
2357 );
2358 assert_eq!(
2359 (pool.recommended.min_size(), pool.recommended.max_size()),
2360 (1, 3)
2361 );
2362
2363 let plan =
2366 plan_compute(&stack_with_container(), Platform::Aws, None).expect("plan should build");
2367 assert_eq!(
2368 plan.pools[0].scale,
2369 CapacityGroupScalePolicy::Autoscale {
2370 min: range(1, 100, 1),
2371 max: range(1, 100, 1),
2372 }
2373 );
2374 assert!(matches!(
2375 plan.pools[0].recommended,
2376 ComputePoolSelection::Fixed { machines: 1, .. }
2377 ));
2378 assert!(plan.pools[0].errors.is_empty());
2379 }
2380
2381 #[test]
2382 fn generated_pool_rejects_a_fleet_too_small_for_desired_replicas() {
2383 let mut stack = stack_with_container();
2384 let api = stack
2385 .resources
2386 .get_mut("api")
2387 .and_then(|entry| entry.config.downcast_mut::<Container>())
2388 .expect("test stack should contain a container");
2389 api.replicas = Some(3);
2391 let selection = |machines| ComputeSettings {
2392 containers: Default::default(),
2393 pools: [(
2394 "general".to_string(),
2395 ComputePoolSelection::Fixed {
2396 machines,
2397 machine: Some("m7g.xlarge".to_string()),
2398 failure_domains: None,
2399 },
2400 )]
2401 .into_iter()
2402 .collect(),
2403 };
2404
2405 let errors = plan_compute(&stack, Platform::Aws, Some(&selection(1)))
2407 .expect("plan should build")
2408 .pools
2409 .remove(0)
2410 .errors;
2411 assert_eq!(errors.len(), 1, "{errors:?}");
2412 assert!(
2413 errors[0].starts_with(
2414 "Pool 'general' is too small for its workloads: 1 x m7g.xlarge has 3.50 vCPU"
2415 ),
2416 "{errors:?}"
2417 );
2418 assert!(errors[0].contains("request 6.00 vCPU"), "{errors:?}");
2419
2420 let errors = plan_compute(&stack, Platform::Aws, Some(&selection(2)))
2421 .expect("plan should build")
2422 .pools
2423 .remove(0)
2424 .errors;
2425 assert!(errors.is_empty(), "{errors:?}");
2426 }
2427
2428 #[test]
2429 fn fixed_and_equal_autoscale_bounds_are_interchangeable_for_declared_pools() {
2430 let fixed = |machines| ComputePoolSelection::Fixed {
2431 machines,
2432 machine: Some("m7g.xlarge".to_string()),
2433 failure_domains: None,
2434 };
2435 let autoscale = |min, max| ComputePoolSelection::Autoscale {
2436 min,
2437 max,
2438 machine: Some("m7g.xlarge".to_string()),
2439 failure_domains: None,
2440 };
2441 let declared_fixed = CapacityGroupScalePolicy::Fixed {
2442 machines: range(2, 4, 2),
2443 };
2444 let declared_autoscale = CapacityGroupScalePolicy::Autoscale {
2445 min: range(1, 3, 1),
2446 max: range(2, 6, 4),
2447 };
2448
2449 assert!(declared_pool_errors(declared_fixed.clone(), autoscale(3, 3)).is_empty());
2450 assert_eq!(
2451 declared_pool_errors(declared_fixed.clone(), autoscale(2, 4)),
2452 vec!["Pool 'general' must use fixed mode with 2-4 machines; autoscale 2-4 is not allowed"]
2453 );
2454 assert_eq!(
2455 declared_pool_errors(declared_fixed, autoscale(5, 5)),
2456 vec!["Pool 'general' machine count 5 is outside the allowed range 2-4"]
2457 );
2458
2459 assert!(declared_pool_errors(declared_autoscale.clone(), fixed(3)).is_empty());
2461 assert_eq!(
2462 declared_pool_errors(declared_autoscale.clone(), fixed(1)),
2463 vec!["Pool 'general' fixed machine count 1 is outside the allowed range 2-3"]
2464 );
2465 assert_eq!(
2466 declared_pool_errors(
2467 CapacityGroupScalePolicy::Autoscale {
2468 min: range(1, 1, 1),
2469 max: range(3, 5, 3),
2470 },
2471 fixed(1),
2472 ),
2473 vec!["Pool 'general' must use autoscale mode (minimum 1-1, maximum 3-5)"]
2474 );
2475 assert_eq!(
2476 declared_pool_errors(declared_autoscale, autoscale(1, 7)),
2477 vec!["Pool 'general' autoscale maximum 7 is outside the allowed range 2-6"]
2478 );
2479 }
2480
2481 #[test]
2482 fn local_plan_has_no_provider_machine_choices() {
2483 let stack = stack_with_container();
2484
2485 let plan = plan_compute(&stack, Platform::Local, None).expect("plan should build");
2486
2487 let pool = plan.pools.first().expect("general pool should exist");
2488 assert_eq!(pool.selected.machine(), None);
2489 assert!(pool.machines.is_empty());
2490 assert!(pool.errors.is_empty());
2491 }
2492}