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