1use crate::{
8 instance_catalog::{self, Architecture, WorkloadRequirements},
9 CapacityGroup, CapacityGroupScalePolicy, ComputeChoiceRange, ComputePoolSelection, Container,
10 Daemon, ErrorData, FailureDomainSelection, GpuSpec, MachineProfile, Platform, ResourceSpec,
11 Stack,
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}
25
26#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
28#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
29#[serde(rename_all = "camelCase")]
30pub struct ComputePoolPlan {
31 pub pool_id: String,
33 pub workloads: Vec<String>,
35 pub requirements: MachineProfile,
37 pub scale: CapacityGroupScalePolicy,
39 pub selected: ComputePoolSelection,
41 pub recommended: ComputePoolSelection,
43 pub machines: Vec<ComputeMachineOption>,
45 #[serde(default, skip_serializing_if = "Vec::is_empty")]
47 pub errors: Vec<String>,
48}
49
50#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
52#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
53#[serde(rename_all = "camelCase")]
54pub struct ComputeMachineOption {
55 pub machine: String,
57 pub profile: MachineProfile,
59 pub recommended: bool,
61}
62
63pub fn default_persistent_failure_domains() -> FailureDomainSelection {
67 FailureDomainSelection {
68 spread: 1,
69 selected_failure_domains: Vec::new(),
70 }
71}
72
73pub fn plan_compute(
75 stack: &Stack,
76 platform: Platform,
77 selected_settings: Option<&crate::ComputeSettings>,
78) -> Result<ComputePlan, ErrorData> {
79 let mut groups = collect_workload_groups(stack)?;
80 merge_explicit_compute_groups(stack, &mut groups)?;
81
82 let mut pool_ids: Vec<String> = groups.keys().cloned().collect();
83 pool_ids.sort();
84
85 let mut pools = Vec::new();
86 for pool_id in pool_ids {
87 let mut group = groups.remove(&pool_id).expect("pool id came from map keys");
88 let requirements = group.requirements;
89 let selected = selected_settings.and_then(|settings| settings.pools.get(&pool_id));
90 if group.generated {
91 group.scale = generated_pool_scale_policy(
92 group.scale.default_min_size(),
93 group.scale.default_max_size(),
94 selected.map(ComputePoolSelection::max_size),
95 );
96 }
97 let recommended = recommended_selection(
98 platform,
99 &requirements,
100 &group.scale,
101 group.requires_failure_domain,
102 group.generated,
103 )?;
104 let mut selected_choice = selected.cloned().unwrap_or_else(|| recommended.clone());
105 if selected_choice.failure_domains().is_none() {
106 if let Some(default_failure_domains) = recommended.failure_domains().cloned() {
107 match &mut selected_choice {
108 ComputePoolSelection::Fixed {
109 failure_domains, ..
110 }
111 | ComputePoolSelection::Autoscale {
112 failure_domains, ..
113 } => *failure_domains = Some(default_failure_domains),
114 }
115 }
116 }
117 let mut errors = validate_compute_pool_selection(
118 platform,
119 &pool_id,
120 &selected_choice,
121 &requirements,
122 &group.scale,
123 );
124 if !group.workloads.is_empty() {
127 if let Err(message) =
128 check_pool_capacity(platform, &pool_id, &selected_choice, &requirements)
129 {
130 errors.push(message);
131 }
132 }
133 let machines = machine_options(platform, &requirements, selected_choice.machine())?;
134
135 pools.push(ComputePoolPlan {
136 pool_id,
137 workloads: group.workloads,
138 requirements: requirements_to_profile(&requirements),
139 scale: group.scale,
140 selected: selected_choice,
141 recommended,
142 machines,
143 errors,
144 });
145 }
146
147 Ok(ComputePlan { pools })
148}
149
150#[derive(Debug, Clone)]
151struct PlannedGroup {
152 workloads: Vec<String>,
153 requirements: WorkloadRequirements,
154 scale: CapacityGroupScalePolicy,
155 requires_failure_domain: bool,
156 generated: bool,
159}
160
161pub const GENERATED_POOL_MAX_MACHINES: u32 = 100;
166
167pub fn generated_pool_scale_policy(
179 default_min: u32,
180 default_max: u32,
181 selected_max: Option<u32>,
182) -> CapacityGroupScalePolicy {
183 let offered = GENERATED_POOL_MAX_MACHINES;
184 let ceiling = offered.max(selected_max.unwrap_or(0));
185 let default_min = default_min.min(offered);
186 CapacityGroupScalePolicy::Autoscale {
187 min: ComputeChoiceRange {
188 min: default_min,
189 max: ceiling,
190 default: default_min,
191 },
192 max: ComputeChoiceRange {
193 min: default_min.max(1),
194 max: ceiling,
195 default: default_max.clamp(default_min.max(1), offered),
196 },
197 }
198}
199
200pub fn check_pool_capacity(
204 platform: Platform,
205 pool_id: &str,
206 selection: &ComputePoolSelection,
207 requirements: &WorkloadRequirements,
208) -> std::result::Result<(), String> {
209 let Some(spec) = selection
210 .machine()
211 .and_then(|machine| instance_catalog::find_instance_type(platform, machine))
212 else {
213 return Ok(());
215 };
216 let machines = selection.max_size();
217 let cpu = f64::from(machines) * f64::from(spec.vcpu);
218 let memory = u64::from(machines) * spec.memory_bytes;
219 if cpu + f64::EPSILON >= requirements.total_cpu_at_desired
220 && memory >= requirements.total_memory_bytes_at_desired
221 {
222 return Ok(());
223 }
224 const GIB: f64 = 1024.0 * 1024.0 * 1024.0;
225 Err(format!(
226 "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",
227 spec.name,
228 memory as f64 / GIB,
229 requirements.total_cpu_at_desired,
230 requirements.total_memory_bytes_at_desired as f64 / GIB,
231 ))
232}
233
234fn collect_workload_groups(stack: &Stack) -> Result<HashMap<String, PlannedGroup>, ErrorData> {
235 let mut groups: HashMap<String, Vec<Workload>> = HashMap::new();
236
237 for entry in stack.resources.values() {
238 if let Some(container) = entry.config.downcast_ref::<Container>() {
239 groups
240 .entry(
241 container
242 .pool
243 .clone()
244 .unwrap_or_else(|| needed_container_pool(container).to_string()),
245 )
246 .or_default()
247 .push(Workload::from_container(container)?);
248 }
249 if let Some(daemon) = entry.config.downcast_ref::<Daemon>() {
250 if daemon.cluster.is_some() {
251 groups
252 .entry(daemon.pool.clone().unwrap_or_else(|| "general".to_string()))
253 .or_default()
254 .push(Workload::from_daemon(daemon)?);
255 }
256 }
257 }
258
259 let mut planned = HashMap::new();
260 for (pool_id, workloads) in groups {
261 let requirements = aggregate_workloads(&workloads);
262 let requires_failure_domain = workloads
263 .iter()
264 .any(|workload| workload.requires_failure_domain);
265 let min_size = default_min_machines(&requirements);
266 let max_size = default_max_machines(&requirements);
267 planned.insert(
268 pool_id,
269 PlannedGroup {
270 workloads: workloads.into_iter().map(|w| w.id).collect(),
271 scale: CapacityGroupScalePolicy::from_selected_bounds(min_size, max_size),
273 requirements,
274 requires_failure_domain,
275 generated: true,
276 },
277 );
278 }
279 Ok(planned)
280}
281
282fn merge_explicit_compute_groups(
283 stack: &Stack,
284 groups: &mut HashMap<String, PlannedGroup>,
285) -> Result<(), ErrorData> {
286 for entry in stack.resources.values() {
287 let Some(cluster) = entry.config.downcast_ref::<crate::ComputeCluster>() else {
288 continue;
289 };
290 for group in &cluster.capacity_groups {
291 let explicit_requirements = profile_to_requirements(
292 group.profile.as_ref(),
293 group.nested_virtualization.unwrap_or(false),
294 );
295 let scale = group.scale_policy.clone().unwrap_or_else(|| {
296 CapacityGroupScalePolicy::from_selected_bounds(group.min_size, group.max_size)
297 });
298 groups
299 .entry(group.group_id.clone())
300 .and_modify(|planned| {
301 if group.instance_type.is_none() {
302 merge_requirements(&mut planned.requirements, &explicit_requirements);
303 } else {
304 planned.requirements.architecture = planned
311 .requirements
312 .architecture
313 .or(explicit_requirements.architecture);
314 planned.requirements.nested_virt |= explicit_requirements.nested_virt;
315 }
316 planned.scale = merge_scale_policy(&planned.scale, &scale);
317 planned.generated = false;
318 })
319 .or_insert_with(|| PlannedGroup {
320 workloads: Vec::new(),
321 scale,
322 requirements: explicit_requirements,
323 requires_failure_domain: false,
324 generated: false,
325 });
326 }
327 }
328 Ok(())
329}
330
331fn recommended_selection(
332 platform: Platform,
333 requirements: &WorkloadRequirements,
334 scale: &CapacityGroupScalePolicy,
335 requires_failure_domain: bool,
336 generated: bool,
337) -> Result<ComputePoolSelection, ErrorData> {
338 let machine = match platform {
339 Platform::Aws | Platform::Gcp | Platform::Azure => Some(
340 instance_catalog::select_instance_type(platform, requirements)
341 .map_err(|message| {
342 AlienError::new(ErrorData::GenericError {
343 message: format!("Failed to select {platform} machine: {message}"),
344 })
345 })?
346 .instance_type
347 .to_string(),
348 ),
349 Platform::Local | Platform::Kubernetes | Platform::Machines | Platform::Test => None,
350 };
351
352 let failure_domains = (requires_failure_domain
353 && matches!(platform, Platform::Aws | Platform::Gcp | Platform::Azure))
354 .then(default_persistent_failure_domains);
355
356 match scale {
357 CapacityGroupScalePolicy::Fixed { machines } => Ok(ComputePoolSelection::Fixed {
358 machines: machines.default.max(1),
359 machine,
360 failure_domains,
361 }),
362 CapacityGroupScalePolicy::Autoscale { min, max }
364 if generated && min.default == max.default =>
365 {
366 Ok(ComputePoolSelection::Fixed {
367 machines: min.default.max(1),
368 machine,
369 failure_domains,
370 })
371 }
372 CapacityGroupScalePolicy::Autoscale { min, max } => Ok(ComputePoolSelection::Autoscale {
373 min: min.default,
374 max: max.default.max(min.default),
375 machine,
376 failure_domains,
377 }),
378 }
379}
380
381pub fn validate_compute_pool_selection(
384 platform: Platform,
385 pool_id: &str,
386 selection: &ComputePoolSelection,
387 requirements: &WorkloadRequirements,
388 scale: &CapacityGroupScalePolicy,
389) -> Vec<String> {
390 let mut errors = Vec::new();
391 if let Err(message) = selection.validate() {
392 errors.push(message);
393 }
394 if let Err(message) = validate_selection_against_scale(selection, scale) {
395 errors.push(format!("Pool '{pool_id}' {message}"));
396 }
397 if matches!(platform, Platform::Aws | Platform::Gcp | Platform::Azure) {
398 match selection.machine() {
399 Some(machine) => match instance_catalog::find_instance_type(platform, machine) {
400 Some(spec) => {
401 let architecture = requirements.architecture.unwrap_or(spec.architecture);
402 if !instance_satisfies(spec, requirements, architecture) {
403 errors.push(format!(
404 "{} machine '{}' does not satisfy pool '{}' requirements",
405 platform, machine, pool_id
406 ));
407 }
408 }
409 None => errors.push(format!(
410 "Unknown {} machine '{}' for pool '{}'",
411 platform, machine, pool_id
412 )),
413 },
414 None => errors.push(format!(
415 "Pool '{}' requires a provider machine on {}",
416 pool_id, platform
417 )),
418 }
419 }
420 errors
421}
422
423pub fn capacity_group_requirements(group: &CapacityGroup) -> WorkloadRequirements {
425 profile_to_requirements(
426 group.profile.as_ref(),
427 group.nested_virtualization.unwrap_or(false),
428 )
429}
430
431fn machine_options(
432 platform: Platform,
433 requirements: &WorkloadRequirements,
434 selected_machine: Option<&str>,
435) -> Result<Vec<ComputeMachineOption>, ErrorData> {
436 if !matches!(platform, Platform::Aws | Platform::Gcp | Platform::Azure) {
437 return Ok(Vec::new());
438 }
439 let recommended =
440 instance_catalog::select_instance_type(platform, requirements).map_err(|message| {
441 AlienError::new(ErrorData::GenericError {
442 message: format!("Failed to select {platform} machine: {message}"),
443 })
444 })?;
445 let resolved_architecture = requirements
446 .architecture
447 .or_else(|| {
448 selected_machine.and_then(|machine| {
449 instance_catalog::find_instance_type(platform, machine)
450 .map(|spec| spec.architecture)
451 })
452 })
453 .or(recommended.profile.architecture)
454 .ok_or_else(|| {
455 AlienError::new(ErrorData::GenericError {
456 message: format!("Selected {platform} machine has no CPU architecture"),
457 })
458 })?;
459 let recommended = recommended.instance_type.to_string();
460
461 let mut options: Vec<ComputeMachineOption> = instance_catalog::catalog_for_platform(platform)
462 .into_iter()
463 .filter(|spec| instance_satisfies(spec, requirements, resolved_architecture))
464 .map(|spec| ComputeMachineOption {
465 machine: spec.name.to_string(),
466 profile: spec.to_machine_profile_for_storage(requirements.max_ephemeral_storage_bytes),
467 recommended: spec.name == recommended || Some(spec.name) == selected_machine,
468 })
469 .collect();
470 options.sort_by(|a, b| a.machine.cmp(&b.machine));
471 Ok(options)
472}
473
474fn instance_satisfies(
475 spec: &instance_catalog::InstanceTypeSpec,
476 requirements: &WorkloadRequirements,
477 resolved_architecture: Architecture,
478) -> bool {
479 if spec.architecture != resolved_architecture {
480 return false;
481 }
482 if requirements.nested_virt && !spec.is_nested_virt_capable() {
483 return false;
484 }
485 if spec.vcpu < requirements.max_cpu_per_container.ceil() as u32 {
486 return false;
487 }
488 if spec.memory_bytes < requirements.max_memory_per_container {
489 return false;
490 }
491 if spec.has_configurable_ephemeral_storage() {
492 if crate::instance_catalog::max_configurable_ephemeral_storage_bytes(spec.platform)
493 .is_none_or(|max| requirements.max_ephemeral_storage_bytes > max)
494 {
495 return false;
496 }
497 } else if spec.ephemeral_storage_bytes < requirements.max_ephemeral_storage_bytes {
498 return false;
499 }
500 match (&requirements.gpu, spec.gpu) {
501 (Some(required), Some(actual)) => {
502 (required.gpu_type == "any" || required.gpu_type == actual.gpu_type)
503 && actual.count >= required.count
504 }
505 (Some(_), None) => false,
506 (None, _) => true,
507 }
508}
509
510#[derive(Debug, Clone)]
511struct Workload {
512 id: String,
513 cpu: f64,
514 memory_bytes: u64,
515 desired_replicas: f64,
516 max_replicas: f64,
517 ephemeral_storage_bytes: u64,
518 gpu: Option<GpuSpec>,
519 requires_failure_domain: bool,
520}
521
522impl Workload {
523 fn from_container(container: &Container) -> Result<Self, ErrorData> {
524 let cpu = parse_cpu(&container.id, &container.cpu)?;
525 let memory_bytes = parse_memory(&container.id, &container.memory)?;
526 let desired_replicas = container
527 .autoscaling
528 .as_ref()
529 .map(|a| a.desired)
530 .or(container.replicas)
531 .unwrap_or(1) as f64;
532 let max_replicas = container
533 .autoscaling
534 .as_ref()
535 .map(|a| a.max)
536 .or(container.replicas)
537 .unwrap_or(1) as f64;
538 let ephemeral_storage_bytes = container
539 .ephemeral_storage
540 .as_deref()
541 .map(instance_catalog::parse_memory_bytes)
542 .transpose()
543 .map_err(|message| {
544 AlienError::new(ErrorData::GenericError {
545 message: format!(
546 "Failed to parse ephemeral storage for '{}': {message}",
547 container.id
548 ),
549 })
550 })?
551 .unwrap_or(0);
552
553 Ok(Self {
554 id: container.id.clone(),
555 cpu,
556 memory_bytes,
557 desired_replicas,
558 max_replicas,
559 ephemeral_storage_bytes,
560 gpu: container.gpu.as_ref().map(|gpu| GpuSpec {
561 gpu_type: gpu.gpu_type.clone(),
562 count: gpu.count,
563 }),
564 requires_failure_domain: container.stateful && container.persistent_storage.is_some(),
565 })
566 }
567
568 fn from_daemon(daemon: &Daemon) -> Result<Self, ErrorData> {
569 Ok(Self {
570 id: daemon.id.clone(),
571 cpu: parse_cpu(&daemon.id, &daemon.cpu)?,
572 memory_bytes: parse_memory(&daemon.id, &daemon.memory)?,
573 desired_replicas: 1.0,
574 max_replicas: 1.0,
575 ephemeral_storage_bytes: 0,
576 gpu: None,
577 requires_failure_domain: false,
578 })
579 }
580}
581
582fn parse_cpu(resource_id: &str, spec: &ResourceSpec) -> Result<f64, ErrorData> {
583 instance_catalog::parse_cpu(&spec.desired).map_err(|message| {
584 AlienError::new(ErrorData::GenericError {
585 message: format!(
586 "Failed to parse CPU requirement '{}' for '{}': {message}",
587 spec.desired, resource_id
588 ),
589 })
590 })
591}
592
593fn parse_memory(resource_id: &str, spec: &ResourceSpec) -> Result<u64, ErrorData> {
594 instance_catalog::parse_memory_bytes(&spec.desired).map_err(|message| {
595 AlienError::new(ErrorData::GenericError {
596 message: format!(
597 "Failed to parse memory requirement '{}' for '{}': {message}",
598 spec.desired, resource_id
599 ),
600 })
601 })
602}
603
604fn aggregate_workloads(workloads: &[Workload]) -> WorkloadRequirements {
605 let mut requirements = default_requirements();
606 requirements.total_cpu_at_desired = 0.0;
607 requirements.total_memory_bytes_at_desired = 0;
608 requirements.total_cpu_at_max = 0.0;
609 requirements.total_memory_bytes_at_max = 0;
610 requirements.max_cpu_per_container = 0.0;
611 requirements.max_memory_per_container = 0;
612 requirements.max_ephemeral_storage_bytes = 0;
613 requirements.gpu = None;
614
615 for workload in workloads {
616 requirements.total_cpu_at_desired += workload.cpu * workload.desired_replicas;
617 requirements.total_cpu_at_max += workload.cpu * workload.max_replicas;
618 requirements.total_memory_bytes_at_desired +=
619 (workload.memory_bytes as f64 * workload.desired_replicas) as u64;
620 requirements.total_memory_bytes_at_max +=
621 (workload.memory_bytes as f64 * workload.max_replicas) as u64;
622 requirements.max_cpu_per_container = requirements.max_cpu_per_container.max(workload.cpu);
623 requirements.max_memory_per_container = requirements
624 .max_memory_per_container
625 .max(workload.memory_bytes);
626 requirements.max_ephemeral_storage_bytes = requirements
627 .max_ephemeral_storage_bytes
628 .max(workload.ephemeral_storage_bytes);
629 if requirements.gpu.is_none() {
630 requirements.gpu = workload.gpu.clone();
631 }
632 }
633 requirements
634}
635
636fn default_requirements() -> WorkloadRequirements {
637 WorkloadRequirements {
638 total_cpu_at_desired: 1.0,
639 total_memory_bytes_at_desired: 2 * 1024 * 1024 * 1024,
640 total_cpu_at_max: 1.0,
641 total_memory_bytes_at_max: 2 * 1024 * 1024 * 1024,
642 max_cpu_per_container: 1.0,
643 max_memory_per_container: 2 * 1024 * 1024 * 1024,
644 max_ephemeral_storage_bytes: 0,
645 gpu: None,
646 architecture: None,
647 nested_virt: false,
648 }
649}
650
651fn profile_to_requirements(
652 profile: Option<&MachineProfile>,
653 nested_virt: bool,
654) -> WorkloadRequirements {
655 let Some(profile) = profile else {
656 return WorkloadRequirements {
657 nested_virt,
658 ..default_requirements()
659 };
660 };
661 let cpu = instance_catalog::parse_cpu(&profile.cpu).unwrap_or(1.0);
662 WorkloadRequirements {
663 total_cpu_at_desired: cpu,
664 total_memory_bytes_at_desired: profile.memory_bytes,
665 total_cpu_at_max: cpu,
666 total_memory_bytes_at_max: profile.memory_bytes,
667 max_cpu_per_container: cpu,
668 max_memory_per_container: profile.memory_bytes,
669 max_ephemeral_storage_bytes: profile.ephemeral_storage_bytes,
670 gpu: profile.gpu.clone(),
671 architecture: profile.architecture,
672 nested_virt,
673 }
674}
675
676fn merge_requirements(existing: &mut WorkloadRequirements, declared: &WorkloadRequirements) {
677 existing.total_cpu_at_desired = existing
678 .total_cpu_at_desired
679 .max(declared.total_cpu_at_desired);
680 existing.total_memory_bytes_at_desired = existing
681 .total_memory_bytes_at_desired
682 .max(declared.total_memory_bytes_at_desired);
683 existing.total_cpu_at_max = existing.total_cpu_at_max.max(declared.total_cpu_at_max);
684 existing.total_memory_bytes_at_max = existing
685 .total_memory_bytes_at_max
686 .max(declared.total_memory_bytes_at_max);
687 existing.max_cpu_per_container = existing
688 .max_cpu_per_container
689 .max(declared.max_cpu_per_container);
690 existing.max_memory_per_container = existing
691 .max_memory_per_container
692 .max(declared.max_memory_per_container);
693 existing.max_ephemeral_storage_bytes = existing
694 .max_ephemeral_storage_bytes
695 .max(declared.max_ephemeral_storage_bytes);
696 if existing.gpu.is_none() {
697 existing.gpu = declared.gpu.clone();
698 }
699 existing.nested_virt |= declared.nested_virt;
700 if existing.architecture.is_none() {
701 existing.architecture = declared.architecture;
702 }
703}
704
705fn requirements_to_profile(requirements: &WorkloadRequirements) -> MachineProfile {
706 MachineProfile {
707 cpu: requirements.max_cpu_per_container.to_string(),
708 memory_bytes: requirements.max_memory_per_container,
709 ephemeral_storage_bytes: requirements.max_ephemeral_storage_bytes,
710 architecture: requirements.architecture,
711 gpu: requirements.gpu.clone(),
712 }
713}
714
715fn merge_scale_policy(
716 existing: &CapacityGroupScalePolicy,
717 declared: &CapacityGroupScalePolicy,
718) -> CapacityGroupScalePolicy {
719 match (existing, declared) {
720 (
721 CapacityGroupScalePolicy::Fixed {
722 machines: existing_machines,
723 },
724 CapacityGroupScalePolicy::Fixed {
725 machines: declared_machines,
726 },
727 ) => CapacityGroupScalePolicy::Fixed {
728 machines: merge_choice_range(existing_machines, declared_machines),
729 },
730 (_, declared) => declared.clone(),
731 }
732}
733
734fn merge_choice_range(
735 existing: &ComputeChoiceRange,
736 declared: &ComputeChoiceRange,
737) -> ComputeChoiceRange {
738 ComputeChoiceRange {
739 min: existing.min.max(declared.min),
740 max: existing.max.max(declared.max),
741 default: declared.default,
742 }
743}
744
745fn validate_selection_against_scale(
746 selection: &ComputePoolSelection,
747 scale: &CapacityGroupScalePolicy,
748) -> std::result::Result<(), String> {
749 match (selection, scale) {
752 (
753 ComputePoolSelection::Fixed { machines, .. },
754 CapacityGroupScalePolicy::Fixed { machines: allowed },
755 ) => check_machine_count("fixed machine count", *machines, allowed.min, allowed.max),
756 (
757 ComputePoolSelection::Autoscale { min, max, .. },
758 CapacityGroupScalePolicy::Autoscale {
759 min: allowed_min,
760 max: allowed_max,
761 },
762 ) => {
763 check_machine_count("autoscale minimum", *min, allowed_min.min, allowed_min.max)?;
764 check_machine_count("autoscale maximum", *max, allowed_max.min, allowed_max.max)
765 }
766 (
767 ComputePoolSelection::Fixed { machines, .. },
768 CapacityGroupScalePolicy::Autoscale {
769 min: allowed_min,
770 max: allowed_max,
771 },
772 ) => {
773 let lowest = allowed_min.min.max(allowed_max.min);
774 let highest = allowed_min.max.min(allowed_max.max);
775 if lowest > highest {
776 return Err(format!(
777 "must use autoscale mode (minimum {}-{}, maximum {}-{})",
778 allowed_min.min, allowed_min.max, allowed_max.min, allowed_max.max
779 ));
780 }
781 check_machine_count("fixed machine count", *machines, lowest, highest)
782 }
783 (
784 ComputePoolSelection::Autoscale { min, max, .. },
785 CapacityGroupScalePolicy::Fixed { machines: allowed },
786 ) => {
787 if min != max {
788 return Err(format!(
789 "must use fixed mode with {}-{} machines; autoscale {min}-{max} is not allowed",
790 allowed.min, allowed.max
791 ));
792 }
793 check_machine_count("machine count", *min, allowed.min, allowed.max)
794 }
795 }
796}
797
798fn check_machine_count(
799 label: &str,
800 value: u32,
801 lowest: u32,
802 highest: u32,
803) -> std::result::Result<(), String> {
804 if (lowest..=highest).contains(&value) {
805 Ok(())
806 } else {
807 Err(format!(
808 "{label} {value} is outside the allowed range {lowest}-{highest}"
809 ))
810 }
811}
812
813fn needed_container_pool(container: &Container) -> &'static str {
814 if container.stateful && container.persistent_storage.is_some() {
815 return "stateful";
816 }
817 if container.gpu.is_some() {
818 return "gpu";
819 }
820 if let Some(storage) = &container.ephemeral_storage {
821 if instance_catalog::parse_memory_bytes(storage).unwrap_or(0) > 200 * 1024 * 1024 * 1024 {
822 return "storage";
823 }
824 }
825 "general"
826}
827
828fn default_min_machines(requirements: &WorkloadRequirements) -> u32 {
829 if requirements.total_cpu_at_desired > 0.0 || requirements.total_memory_bytes_at_desired > 0 {
830 1
831 } else {
832 0
833 }
834}
835
836fn default_max_machines(requirements: &WorkloadRequirements) -> u32 {
837 let min = default_min_machines(requirements);
838 let by_cpu =
839 (requirements.total_cpu_at_max / requirements.max_cpu_per_container.max(1.0)).ceil() as u32;
840 let by_mem = requirements
841 .total_memory_bytes_at_max
842 .div_ceil(requirements.max_memory_per_container.max(1)) as u32;
843 min.max(by_cpu).max(by_mem).max(1)
844}
845
846#[cfg(test)]
847mod tests {
848 use super::*;
849 use crate::{
850 instance_catalog::Architecture, CapacityGroup, CapacityGroupScalePolicy,
851 ComputeChoiceRange, ComputeCluster, ComputeSettings, ContainerCode, DaemonCode, Resource,
852 ResourceEntry, ResourceLifecycle, Stack, Storage, Worker, WorkerCode,
853 };
854
855 fn stack_with_container() -> Stack {
856 let container = Container::new("api".to_string())
857 .code(ContainerCode::Image {
858 image: "api:latest".to_string(),
859 })
860 .cpu(ResourceSpec {
861 min: "1".to_string(),
862 desired: "2".to_string(),
863 })
864 .memory(ResourceSpec {
865 min: "2Gi".to_string(),
866 desired: "4Gi".to_string(),
867 })
868 .permissions("api".to_string())
869 .build();
870 Stack {
871 id: "test".to_string(),
872 resources: [(
873 "api".to_string(),
874 ResourceEntry {
875 config: Resource::new(container),
876 lifecycle: ResourceLifecycle::Live,
877 dependencies: Vec::new(),
878 remote_access: false,
879 enabled_when: None,
880 },
881 )]
882 .into_iter()
883 .collect(),
884 permissions: crate::permissions::PermissionsConfig::default(),
885 supported_platforms: None,
886 inputs: vec![],
887 dynamic_container_repositories: Vec::new(),
888 dynamic_container_image_resources: Vec::new(),
889 operations: None,
890 }
891 }
892
893 #[test]
894 fn serverless_workers_and_storage_do_not_allocate_machine_pools() {
895 let mut stack = stack_with_container();
896 stack.resources.clear();
897 for (id, resource) in [
898 (
899 "api",
900 Resource::new(
901 Worker::new("api".to_string())
902 .code(WorkerCode::Image {
903 image: "example/worker:v1".to_string(),
904 })
905 .permissions("execution".to_string())
906 .build(),
907 ),
908 ),
909 (
910 "data",
911 Resource::new(Storage::new("data".to_string()).build()),
912 ),
913 ] {
914 stack.resources.insert(
915 id.to_string(),
916 ResourceEntry {
917 config: resource,
918 lifecycle: ResourceLifecycle::Frozen,
919 dependencies: Vec::new(),
920 remote_access: false,
921 enabled_when: None,
922 },
923 );
924 }
925
926 for platform in [Platform::Aws, Platform::Gcp, Platform::Azure] {
927 let plan = plan_compute(&stack, platform, None).expect("plan should build");
928 assert!(plan.pools.is_empty(), "serverless plan allocated {plan:?}");
929 }
930 }
931
932 #[test]
933 fn empty_stack_does_not_allocate_machine_pools() {
934 let mut stack = stack_with_container();
935 stack.resources.clear();
936 let plan = plan_compute(&stack, Platform::Aws, None).expect("plan should build");
937 assert!(plan.pools.is_empty());
938 }
939
940 #[test]
941 fn explicit_cluster_without_workloads_only_plans_declared_pools() {
942 let mut stack = stack_with_container();
943 stack.resources.clear();
944 let cluster = ComputeCluster::new("compute".to_string())
945 .capacity_group(CapacityGroup {
946 group_id: "batch".to_string(),
947 instance_type: Some("t4g.medium".to_string()),
948 profile: None,
949 min_size: 1,
950 max_size: 1,
951 scale_policy: None,
952 nested_virtualization: None,
953 })
954 .build();
955 stack.resources.insert(
956 "compute".to_string(),
957 ResourceEntry {
958 config: Resource::new(cluster),
959 lifecycle: ResourceLifecycle::Frozen,
960 dependencies: Vec::new(),
961 remote_access: false,
962 enabled_when: None,
963 },
964 );
965
966 let plan = plan_compute(&stack, Platform::Aws, None).expect("plan should build");
967 assert_eq!(plan.pools.len(), 1);
968 assert_eq!(plan.pools[0].pool_id, "batch");
969 assert!(plan.pools[0].errors.is_empty());
970 }
971
972 #[test]
973 fn cloud_plan_recommends_provider_machine_without_mutating_selection() {
974 let stack = stack_with_container();
975
976 let plan = plan_compute(&stack, Platform::Aws, None).expect("plan should build");
977
978 let pool = plan.pools.first().expect("general pool should exist");
979 assert_eq!(pool.pool_id, "general");
980 assert_eq!(pool.workloads, vec!["api"]);
981 assert!(pool.selected.machine().is_some());
982 assert!(pool.machines.iter().any(|machine| machine.recommended));
983 }
984
985 #[test]
986 fn cloud_plan_only_offers_machines_matching_the_default_image_target() {
987 let stack = stack_with_container();
988
989 for platform in [Platform::Aws, Platform::Gcp, Platform::Azure] {
990 let plan = plan_compute(&stack, platform, None).expect("plan should build");
991 let pool = plan.pools.first().expect("general pool should exist");
992 let expected = instance_catalog::default_architecture(platform)
993 .expect("managed cloud should have a default architecture");
994
995 assert!(pool.machines.iter().all(|machine| {
996 instance_catalog::find_instance_type(platform, &machine.machine)
997 .is_some_and(|spec| spec.architecture == expected)
998 }));
999 }
1000 }
1001
1002 #[test]
1003 fn selected_machine_is_preserved_as_static_deployment_choice() {
1004 let stack = stack_with_container();
1005 let settings = ComputeSettings {
1006 pools: [(
1007 "general".to_string(),
1008 ComputePoolSelection::Fixed {
1009 machines: 1,
1010 machine: Some("m7g.xlarge".to_string()),
1011 failure_domains: None,
1012 },
1013 )]
1014 .into_iter()
1015 .collect(),
1016 };
1017
1018 let plan = plan_compute(&stack, Platform::Aws, Some(&settings)).expect("plan should build");
1019
1020 let pool = plan.pools.first().expect("general pool should exist");
1021 assert_eq!(pool.selected.machine(), Some("m7g.xlarge"));
1022 assert!(pool.errors.is_empty());
1023 }
1024
1025 #[test]
1026 fn configurable_cloud_disk_satisfies_storage_above_fixed_local_catalog() {
1027 let mut stack = stack_with_container();
1028 let container = stack
1029 .resources
1030 .get_mut("api")
1031 .and_then(|entry| entry.config.downcast_mut::<Container>())
1032 .expect("test stack should contain a container");
1033 container.ephemeral_storage = Some("8000Gi".to_string());
1034 let settings = ComputeSettings {
1035 pools: [(
1036 "storage".to_string(),
1037 ComputePoolSelection::Fixed {
1038 machines: 1,
1039 machine: Some("m8i.2xlarge".to_string()),
1040 failure_domains: None,
1041 },
1042 )]
1043 .into_iter()
1044 .collect(),
1045 };
1046
1047 let plan = plan_compute(&stack, Platform::Aws, Some(&settings)).expect("plan should build");
1048 let pool = plan.pools.first().expect("general pool should exist");
1049 assert!(pool.errors.is_empty());
1050 let selected = pool
1051 .machines
1052 .iter()
1053 .find(|option| option.machine == "m8i.2xlarge")
1054 .expect("selected machine should remain an option");
1055 assert_eq!(
1056 selected.profile.ephemeral_storage_bytes,
1057 8_000 * 1024 * 1024 * 1024
1058 );
1059 }
1060
1061 #[test]
1062 fn selected_machine_defines_architecture_when_workloads_do_not() {
1063 let stack = stack_with_container();
1064 let settings = ComputeSettings {
1065 pools: [(
1066 "general".to_string(),
1067 ComputePoolSelection::Fixed {
1068 machines: 1,
1069 machine: Some("m7i.xlarge".to_string()),
1070 failure_domains: None,
1071 },
1072 )]
1073 .into_iter()
1074 .collect(),
1075 };
1076
1077 let plan = plan_compute(&stack, Platform::Aws, Some(&settings)).expect("plan should build");
1078
1079 let pool = plan.pools.first().expect("general pool should exist");
1080 assert!(pool.errors.is_empty());
1081 assert!(pool.machines.iter().all(|machine| {
1082 instance_catalog::find_instance_type(Platform::Aws, &machine.machine)
1083 .is_some_and(|spec| spec.architecture == Architecture::X86_64)
1084 }));
1085 }
1086
1087 #[test]
1088 fn graviton4_compute_machine_can_be_selected() {
1089 let stack = stack_with_container();
1090 let settings = ComputeSettings {
1091 pools: [(
1092 "general".to_string(),
1093 ComputePoolSelection::Fixed {
1094 machines: 1,
1095 machine: Some("c8g.2xlarge".to_string()),
1096 failure_domains: None,
1097 },
1098 )]
1099 .into_iter()
1100 .collect(),
1101 };
1102
1103 let plan = plan_compute(&stack, Platform::Aws, Some(&settings)).expect("plan should build");
1104
1105 let pool = plan.pools.first().expect("general pool should exist");
1106 assert!(pool.errors.is_empty(), "{:?}", pool.errors);
1107 let selected = pool
1108 .machines
1109 .iter()
1110 .find(|option| option.machine == "c8g.2xlarge")
1111 .expect("selected machine should be offered");
1112 assert!(selected.recommended);
1113 assert_eq!(selected.profile.architecture, Some(Architecture::Arm64));
1114 }
1115
1116 #[test]
1117 fn explicit_capacity_group_requirements_are_merged_with_workloads() {
1118 let mut stack = stack_with_container();
1119 let cluster = ComputeCluster::new("compute".to_string())
1120 .capacity_group(CapacityGroup {
1121 group_id: "general".to_string(),
1122 instance_type: None,
1123 profile: Some(MachineProfile {
1124 cpu: "4".to_string(),
1125 memory_bytes: 16 * 1024 * 1024 * 1024,
1126 ephemeral_storage_bytes: 20 * 1024 * 1024 * 1024,
1127 architecture: Some(Architecture::X86_64),
1128 gpu: None,
1129 }),
1130 min_size: 2,
1131 max_size: 5,
1132 scale_policy: None,
1133 nested_virtualization: Some(true),
1134 })
1135 .build();
1136 stack.resources.insert(
1137 "compute".to_string(),
1138 ResourceEntry {
1139 config: Resource::new(cluster),
1140 lifecycle: ResourceLifecycle::Frozen,
1141 dependencies: Vec::new(),
1142 remote_access: false,
1143 enabled_when: None,
1144 },
1145 );
1146
1147 let plan = plan_compute(&stack, Platform::Aws, None).expect("plan should build");
1148
1149 let pool = plan.pools.first().expect("general pool should exist");
1150 let machine = pool
1151 .selected
1152 .machine()
1153 .expect("AWS selection should include a machine");
1154 let spec = instance_catalog::find_instance_type(Platform::Aws, machine)
1155 .expect("selected machine should exist in the catalog");
1156 assert!(spec.is_nested_virt_capable());
1157 assert_eq!(pool.selected.min_size(), 2);
1158 assert_eq!(pool.selected.max_size(), 5);
1159 assert!(pool.errors.is_empty());
1160 }
1161
1162 #[test]
1163 fn materialized_machine_profile_is_not_counted_as_workload_demand() {
1164 let mut stack = stack_with_container();
1165 let selected = instance_catalog::find_instance_type(Platform::Aws, "m7g.xlarge")
1166 .expect("machine should exist in catalog");
1167 let cluster = ComputeCluster::new("compute".to_string())
1168 .capacity_group(CapacityGroup {
1169 group_id: "general".to_string(),
1170 instance_type: Some(selected.name.to_string()),
1171 profile: Some(selected.to_machine_profile()),
1172 min_size: 1,
1173 max_size: 1,
1174 scale_policy: None,
1175 nested_virtualization: None,
1176 })
1177 .build();
1178 stack.resources.insert(
1179 "compute".to_string(),
1180 ResourceEntry {
1181 config: Resource::new(cluster),
1182 lifecycle: ResourceLifecycle::Frozen,
1183 dependencies: Vec::new(),
1184 remote_access: false,
1185 enabled_when: None,
1186 },
1187 );
1188
1189 let plan = plan_compute(&stack, Platform::Aws, None).expect("plan should build");
1190 let pool = plan.pools.first().expect("general pool should exist");
1191
1192 assert_eq!(pool.requirements.cpu, "2");
1193 assert_eq!(pool.requirements.memory_bytes, 4 * 1024 * 1024 * 1024);
1194 assert_eq!(pool.recommended.machine(), Some("m7g.xlarge"));
1195 assert!(pool
1196 .machines
1197 .iter()
1198 .any(|option| option.machine == "m7g.xlarge"));
1199 }
1200
1201 #[test]
1202 fn nested_x86_fixed_range_pool_preserves_bounds_and_rejects_graviton() {
1203 let daemon = Daemon::new("vm-runtime-loader".to_string())
1204 .code(DaemonCode::Image {
1205 image: "example.com/vm-runtime:latest".to_string(),
1206 })
1207 .cluster("vm-runtime".to_string())
1208 .cpu(ResourceSpec {
1209 min: "2".to_string(),
1210 desired: "2".to_string(),
1211 })
1212 .memory(ResourceSpec {
1213 min: "4Gi".to_string(),
1214 desired: "4Gi".to_string(),
1215 })
1216 .permissions("loader".to_string())
1217 .build();
1218 let cluster = ComputeCluster::new("vm-runtime".to_string())
1219 .capacity_group(CapacityGroup {
1220 group_id: "general".to_string(),
1221 instance_type: None,
1222 profile: Some(MachineProfile {
1223 cpu: "4".to_string(),
1224 memory_bytes: 16 * 1024 * 1024 * 1024,
1225 ephemeral_storage_bytes: 20 * 1024 * 1024 * 1024,
1226 architecture: Some(Architecture::X86_64),
1227 gpu: None,
1228 }),
1229 min_size: 2,
1230 max_size: 2,
1231 scale_policy: Some(CapacityGroupScalePolicy::Fixed {
1232 machines: ComputeChoiceRange {
1233 min: 1,
1234 max: 5,
1235 default: 2,
1236 },
1237 }),
1238 nested_virtualization: Some(true),
1239 })
1240 .build();
1241 let stack = Stack {
1242 id: "vm-runtime".to_string(),
1243 resources: [
1244 (
1245 "vm-runtime-loader".to_string(),
1246 ResourceEntry {
1247 config: Resource::new(daemon),
1248 lifecycle: ResourceLifecycle::Live,
1249 dependencies: Vec::new(),
1250 remote_access: false,
1251 enabled_when: None,
1252 },
1253 ),
1254 (
1255 "vm-runtime".to_string(),
1256 ResourceEntry {
1257 config: Resource::new(cluster),
1258 lifecycle: ResourceLifecycle::Frozen,
1259 dependencies: Vec::new(),
1260 remote_access: false,
1261 enabled_when: None,
1262 },
1263 ),
1264 ]
1265 .into_iter()
1266 .collect(),
1267 permissions: crate::permissions::PermissionsConfig::default(),
1268 supported_platforms: None,
1269 inputs: vec![],
1270 dynamic_container_repositories: Vec::new(),
1271 dynamic_container_image_resources: Vec::new(),
1272 operations: None,
1273 };
1274
1275 let plan = plan_compute(&stack, Platform::Aws, None).expect("plan should build");
1276 let pool = plan.pools.first().expect("general pool should exist");
1277 assert_eq!(pool.recommended.machine(), Some("m8i.2xlarge"));
1278 assert_eq!(pool.recommended.min_size(), 2);
1279 assert_eq!(pool.recommended.max_size(), 2);
1280 assert_eq!(
1281 pool.scale,
1282 CapacityGroupScalePolicy::Fixed {
1283 machines: ComputeChoiceRange {
1284 min: 1,
1285 max: 5,
1286 default: 2,
1287 },
1288 }
1289 );
1290 assert!(!pool
1291 .machines
1292 .iter()
1293 .any(|option| option.machine == "m7g.2xlarge"));
1294
1295 let invalid_settings = ComputeSettings {
1296 pools: [(
1297 "general".to_string(),
1298 ComputePoolSelection::Fixed {
1299 machines: 2,
1300 machine: Some("m7g.2xlarge".to_string()),
1301 failure_domains: None,
1302 },
1303 )]
1304 .into_iter()
1305 .collect(),
1306 };
1307 let invalid_plan = plan_compute(&stack, Platform::Aws, Some(&invalid_settings))
1308 .expect("plan should build");
1309 assert!(!invalid_plan.pools[0].errors.is_empty());
1310 }
1311
1312 fn declared_pool_errors(
1313 scale: CapacityGroupScalePolicy,
1314 selection: ComputePoolSelection,
1315 ) -> Vec<String> {
1316 let mut stack = stack_with_container();
1317 let cluster = ComputeCluster::new("compute".to_string())
1318 .capacity_group(CapacityGroup {
1319 group_id: "general".to_string(),
1320 instance_type: None,
1321 profile: None,
1322 min_size: scale.default_min_size(),
1323 max_size: scale.default_max_size(),
1324 scale_policy: Some(scale),
1325 nested_virtualization: None,
1326 })
1327 .build();
1328 stack.resources.insert(
1329 "compute".to_string(),
1330 ResourceEntry {
1331 config: Resource::new(cluster),
1332 lifecycle: ResourceLifecycle::Frozen,
1333 dependencies: Vec::new(),
1334 remote_access: false,
1335 enabled_when: None,
1336 },
1337 );
1338 let settings = ComputeSettings {
1339 pools: [("general".to_string(), selection)].into_iter().collect(),
1340 };
1341 plan_compute(&stack, Platform::Aws, Some(&settings))
1342 .expect("plan should build")
1343 .pools
1344 .remove(0)
1345 .errors
1346 }
1347
1348 fn range(min: u32, max: u32, default: u32) -> ComputeChoiceRange {
1349 ComputeChoiceRange { min, max, default }
1350 }
1351
1352 #[test]
1353 fn generated_pool_offers_the_recommendation_as_a_default_not_a_bound() {
1354 let mut stack = stack_with_container();
1355 let api = stack
1356 .resources
1357 .get_mut("api")
1358 .and_then(|entry| entry.config.downcast_mut::<Container>())
1359 .expect("test stack should contain a container");
1360 api.autoscaling = Some(crate::ContainerAutoscaling {
1361 min: 1,
1362 desired: 1,
1363 max: 3,
1364 target_cpu_percent: Some(70.0),
1365 target_memory_percent: None,
1366 target_http_in_flight_per_replica: None,
1367 max_http_p95_latency_ms: None,
1368 });
1369
1370 let plan = plan_compute(&stack, Platform::Aws, None).expect("plan should build");
1371 let pool = &plan.pools[0];
1372 assert_eq!(
1373 pool.scale,
1374 CapacityGroupScalePolicy::Autoscale {
1375 min: range(1, 100, 1),
1376 max: range(1, 100, 3),
1377 }
1378 );
1379 assert_eq!(
1380 (pool.recommended.min_size(), pool.recommended.max_size()),
1381 (1, 3)
1382 );
1383
1384 let plan =
1387 plan_compute(&stack_with_container(), Platform::Aws, None).expect("plan should build");
1388 assert_eq!(
1389 plan.pools[0].scale,
1390 CapacityGroupScalePolicy::Autoscale {
1391 min: range(1, 100, 1),
1392 max: range(1, 100, 1),
1393 }
1394 );
1395 assert!(matches!(
1396 plan.pools[0].recommended,
1397 ComputePoolSelection::Fixed { machines: 1, .. }
1398 ));
1399 assert!(plan.pools[0].errors.is_empty());
1400 }
1401
1402 #[test]
1403 fn generated_pool_rejects_a_fleet_too_small_for_desired_replicas() {
1404 let mut stack = stack_with_container();
1405 let api = stack
1406 .resources
1407 .get_mut("api")
1408 .and_then(|entry| entry.config.downcast_mut::<Container>())
1409 .expect("test stack should contain a container");
1410 api.replicas = Some(3);
1412 let selection = |machines| ComputeSettings {
1413 pools: [(
1414 "general".to_string(),
1415 ComputePoolSelection::Fixed {
1416 machines,
1417 machine: Some("m7g.xlarge".to_string()),
1418 failure_domains: None,
1419 },
1420 )]
1421 .into_iter()
1422 .collect(),
1423 };
1424
1425 let errors = plan_compute(&stack, Platform::Aws, Some(&selection(1)))
1427 .expect("plan should build")
1428 .pools
1429 .remove(0)
1430 .errors;
1431 assert_eq!(errors.len(), 1, "{errors:?}");
1432 assert!(
1433 errors[0].starts_with(
1434 "Pool 'general' is too small for its workloads: 1 x m7g.xlarge has 4.00 vCPU"
1435 ),
1436 "{errors:?}"
1437 );
1438 assert!(errors[0].contains("request 6.00 vCPU"), "{errors:?}");
1439
1440 let errors = plan_compute(&stack, Platform::Aws, Some(&selection(2)))
1441 .expect("plan should build")
1442 .pools
1443 .remove(0)
1444 .errors;
1445 assert!(errors.is_empty(), "{errors:?}");
1446 }
1447
1448 #[test]
1449 fn fixed_and_equal_autoscale_bounds_are_interchangeable_for_declared_pools() {
1450 let fixed = |machines| ComputePoolSelection::Fixed {
1451 machines,
1452 machine: Some("m7g.xlarge".to_string()),
1453 failure_domains: None,
1454 };
1455 let autoscale = |min, max| ComputePoolSelection::Autoscale {
1456 min,
1457 max,
1458 machine: Some("m7g.xlarge".to_string()),
1459 failure_domains: None,
1460 };
1461 let declared_fixed = CapacityGroupScalePolicy::Fixed {
1462 machines: range(2, 4, 2),
1463 };
1464 let declared_autoscale = CapacityGroupScalePolicy::Autoscale {
1465 min: range(1, 3, 1),
1466 max: range(2, 6, 4),
1467 };
1468
1469 assert!(declared_pool_errors(declared_fixed.clone(), autoscale(3, 3)).is_empty());
1470 assert_eq!(
1471 declared_pool_errors(declared_fixed.clone(), autoscale(2, 4)),
1472 vec!["Pool 'general' must use fixed mode with 2-4 machines; autoscale 2-4 is not allowed"]
1473 );
1474 assert_eq!(
1475 declared_pool_errors(declared_fixed, autoscale(5, 5)),
1476 vec!["Pool 'general' machine count 5 is outside the allowed range 2-4"]
1477 );
1478
1479 assert!(declared_pool_errors(declared_autoscale.clone(), fixed(3)).is_empty());
1481 assert_eq!(
1482 declared_pool_errors(declared_autoscale.clone(), fixed(1)),
1483 vec!["Pool 'general' fixed machine count 1 is outside the allowed range 2-3"]
1484 );
1485 assert_eq!(
1486 declared_pool_errors(
1487 CapacityGroupScalePolicy::Autoscale {
1488 min: range(1, 1, 1),
1489 max: range(3, 5, 3),
1490 },
1491 fixed(1),
1492 ),
1493 vec!["Pool 'general' must use autoscale mode (minimum 1-1, maximum 3-5)"]
1494 );
1495 assert_eq!(
1496 declared_pool_errors(declared_autoscale, autoscale(1, 7)),
1497 vec!["Pool 'general' autoscale maximum 7 is outside the allowed range 2-6"]
1498 );
1499 }
1500
1501 #[test]
1502 fn local_plan_has_no_provider_machine_choices() {
1503 let stack = stack_with_container();
1504
1505 let plan = plan_compute(&stack, Platform::Local, None).expect("plan should build");
1506
1507 let pool = plan.pools.first().expect("general pool should exist");
1508 assert_eq!(pool.selected.machine(), None);
1509 assert!(pool.machines.is_empty());
1510 assert!(pool.errors.is_empty());
1511 }
1512}