Skip to main content

alien_core/
compute_planner.rs

1//! Deployment-time compute planner.
2//!
3//! The planner turns portable stack requirements plus a target platform into a
4//! renderable set of recommended deployment choices. It does not mutate the
5//! stack and does not require database access.
6
7use 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/// Full compute plan for one stack/platform pair.
18#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
19#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
20#[serde(rename_all = "camelCase")]
21pub struct ComputePlan {
22    /// Planned pools in stable pool-id order.
23    pub pools: Vec<ComputePoolPlan>,
24}
25
26/// Planner output for one compute pool.
27#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
28#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
29#[serde(rename_all = "camelCase")]
30pub struct ComputePoolPlan {
31    /// Pool ID from the stack or derived default.
32    pub pool_id: String,
33    /// Workloads assigned to this pool.
34    pub workloads: Vec<String>,
35    /// Aggregated requirements used for machine selection.
36    pub requirements: MachineProfile,
37    /// Allowed scale policy declared by source or derived for generated pools.
38    pub scale: CapacityGroupScalePolicy,
39    /// Recommended or user-selected deployment choice.
40    pub selected: ComputePoolSelection,
41    /// Planner-recommended default.
42    pub recommended: ComputePoolSelection,
43    /// Valid cloud machine choices. Empty for local and Kubernetes.
44    pub machines: Vec<ComputeMachineOption>,
45    /// Validation errors for supplied deployment settings.
46    #[serde(default, skip_serializing_if = "Vec::is_empty")]
47    pub errors: Vec<String>,
48}
49
50/// One concrete provider machine option.
51#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
52#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
53#[serde(rename_all = "camelCase")]
54pub struct ComputeMachineOption {
55    /// Provider machine name.
56    pub machine: String,
57    /// Machine hardware profile.
58    pub profile: MachineProfile,
59    /// Whether this machine is the planner's default recommendation.
60    pub recommended: bool,
61}
62
63/// Failure-domain policy for a pool that hosts persistent stateful workloads when the
64/// deployment does not choose one: one provider-selected domain, so the pool's machines
65/// and their volumes land in the same zone.
66pub fn default_persistent_failure_domains() -> FailureDomainSelection {
67    FailureDomainSelection {
68        spread: 1,
69        selected_failure_domains: Vec::new(),
70    }
71}
72
73/// Compute a deterministic deployment-time plan.
74pub 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        // Only workloads assigned to the pool give it real demand; a declared pool without
125        // them carries just its machine profile.
126        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    /// No capacity group in the source declares this pool. Its scale is the
157    /// planner's recommendation, not a bound the developer chose.
158    generated: bool,
159}
160
161/// Machine count up to which a setup offers choices for a pool the source does not declare.
162///
163/// It is not a limit: such a pool has no upper bound, and a saved choice above it widens the
164/// offered range. Recommendations above it are clamped to it.
165pub const GENERATED_POOL_MAX_MACHINES: u32 = 100;
166
167/// Allowed scale for a pool that no capacity group declares.
168///
169/// The planner derives `default_min`/`default_max` from workload replicas, assuming one
170/// container per machine. They are a recommendation, not a bound: the developer did not bound
171/// the pool, a larger machine fits more containers, and replica counts change between releases
172/// while the installer's saved choice stays. The installer may choose any count from the
173/// workload minimum (one machine when any workload runs) upwards, fixed or autoscaling (a fixed
174/// N is accepted as the autoscale range N..N). The range reaches
175/// [`GENERATED_POOL_MAX_MACHINES`], or `selected_max` when the saved choice is larger, so the
176/// saved choice is always inside it. Whether the choice can hold the workloads is
177/// [`check_pool_capacity`]'s question.
178pub 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
200/// Rejects a selection whose largest fleet cannot hold the workloads at their desired replica
201/// counts. Like the per-container machine check, it compares hardware totals and does not model
202/// packing, so passing is necessary but not sufficient.
203pub 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        // Machine presence and catalog membership are reported by the selection checks.
214        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                // Merged with any declared group below; widened in `plan_compute` if none declares it.
272                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                        // A materialized capacity group profile describes the
305                        // selected machine, not additional workload demand.
306                        // Merging its full CPU and memory into requirements
307                        // would cause deployment package planning to size an
308                        // already-sized machine a second time. Keep only the
309                        // constraints that affect artifact compatibility.
310                        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        // Workloads that never scale need no autoscaling fleet, even where any mode is allowed.
363        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
381/// Validate one selected compute pool against platform machine requirements and
382/// source-declared scale bounds.
383pub 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
423/// Convert a capacity group declaration into planner requirements.
424pub 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    // A fixed pool of N machines and an autoscaling pool bounded to N..N run the same
750    // fleet, so either form is accepted wherever the other one is.
751    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        // Workloads that never scale get a fixed recommendation, but either mode stays
1385        // allowed: replica counts change between releases while the saved choice stays.
1386        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        // 3 x 2 vCPU / 4 GiB requested at the desired count.
1411        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        // m7g.xlarge: 4 vCPU and 16 GiB.
1426        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        // Fixed N must fit both the declared minimum and maximum ranges.
1480        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}