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/// Compute a deterministic deployment-time plan.
64pub fn plan_compute(
65    stack: &Stack,
66    platform: Platform,
67    selected_settings: Option<&crate::ComputeSettings>,
68) -> Result<ComputePlan, ErrorData> {
69    let mut groups = collect_workload_groups(stack)?;
70    merge_explicit_compute_groups(stack, &mut groups)?;
71
72    let mut pool_ids: Vec<String> = groups.keys().cloned().collect();
73    pool_ids.sort();
74
75    let mut pools = Vec::new();
76    for pool_id in pool_ids {
77        let group = groups.remove(&pool_id).expect("pool id came from map keys");
78        let requirements = group.requirements;
79        let selected = selected_settings.and_then(|settings| settings.pools.get(&pool_id));
80        let recommended = recommended_selection(
81            platform,
82            &requirements,
83            &group.scale,
84            group.requires_failure_domain,
85        )?;
86        let mut selected_choice = selected.cloned().unwrap_or_else(|| recommended.clone());
87        if selected_choice.failure_domains().is_none() {
88            if let Some(default_failure_domains) = recommended.failure_domains().cloned() {
89                match &mut selected_choice {
90                    ComputePoolSelection::Fixed {
91                        failure_domains, ..
92                    }
93                    | ComputePoolSelection::Autoscale {
94                        failure_domains, ..
95                    } => *failure_domains = Some(default_failure_domains),
96                }
97            }
98        }
99        let errors = validate_compute_pool_selection(
100            platform,
101            &pool_id,
102            &selected_choice,
103            &requirements,
104            &group.scale,
105        );
106        let machines = machine_options(platform, &requirements, selected_choice.machine())?;
107
108        pools.push(ComputePoolPlan {
109            pool_id,
110            workloads: group.workloads,
111            requirements: requirements_to_profile(&requirements),
112            scale: group.scale,
113            selected: selected_choice,
114            recommended,
115            machines,
116            errors,
117        });
118    }
119
120    Ok(ComputePlan { pools })
121}
122
123#[derive(Debug, Clone)]
124struct PlannedGroup {
125    workloads: Vec<String>,
126    requirements: WorkloadRequirements,
127    scale: CapacityGroupScalePolicy,
128    requires_failure_domain: bool,
129}
130
131fn collect_workload_groups(stack: &Stack) -> Result<HashMap<String, PlannedGroup>, ErrorData> {
132    let mut groups: HashMap<String, Vec<Workload>> = HashMap::new();
133
134    for entry in stack.resources.values() {
135        if let Some(container) = entry.config.downcast_ref::<Container>() {
136            groups
137                .entry(
138                    container
139                        .pool
140                        .clone()
141                        .unwrap_or_else(|| needed_container_pool(container).to_string()),
142                )
143                .or_default()
144                .push(Workload::from_container(container)?);
145        }
146        if let Some(daemon) = entry.config.downcast_ref::<Daemon>() {
147            if daemon.cluster.is_some() {
148                groups
149                    .entry(daemon.pool.clone().unwrap_or_else(|| "general".to_string()))
150                    .or_default()
151                    .push(Workload::from_daemon(daemon)?);
152            }
153        }
154    }
155
156    let mut planned = HashMap::new();
157    for (pool_id, workloads) in groups {
158        let requirements = aggregate_workloads(&workloads);
159        let requires_failure_domain = workloads
160            .iter()
161            .any(|workload| workload.requires_failure_domain);
162        let min_size = default_min_machines(&requirements);
163        let max_size = default_max_machines(&requirements);
164        planned.insert(
165            pool_id,
166            PlannedGroup {
167                workloads: workloads.into_iter().map(|w| w.id).collect(),
168                scale: CapacityGroupScalePolicy::from_selected_bounds(min_size, max_size),
169                requirements,
170                requires_failure_domain,
171            },
172        );
173    }
174    if planned.is_empty() {
175        let requirements = default_requirements();
176        planned.insert(
177            "general".to_string(),
178            PlannedGroup {
179                workloads: Vec::new(),
180                scale: CapacityGroupScalePolicy::from_selected_bounds(1, 1),
181                requirements,
182                requires_failure_domain: false,
183            },
184        );
185    }
186    Ok(planned)
187}
188
189fn merge_explicit_compute_groups(
190    stack: &Stack,
191    groups: &mut HashMap<String, PlannedGroup>,
192) -> Result<(), ErrorData> {
193    for entry in stack.resources.values() {
194        let Some(cluster) = entry.config.downcast_ref::<crate::ComputeCluster>() else {
195            continue;
196        };
197        for group in &cluster.capacity_groups {
198            let explicit_requirements = profile_to_requirements(
199                group.profile.as_ref(),
200                group.nested_virtualization.unwrap_or(false),
201            );
202            let scale = group.scale_policy.clone().unwrap_or_else(|| {
203                CapacityGroupScalePolicy::from_selected_bounds(group.min_size, group.max_size)
204            });
205            groups
206                .entry(group.group_id.clone())
207                .and_modify(|planned| {
208                    merge_requirements(&mut planned.requirements, &explicit_requirements);
209                    planned.scale = merge_scale_policy(&planned.scale, &scale);
210                })
211                .or_insert_with(|| PlannedGroup {
212                    workloads: Vec::new(),
213                    scale,
214                    requirements: explicit_requirements,
215                    requires_failure_domain: false,
216                });
217        }
218    }
219    Ok(())
220}
221
222fn recommended_selection(
223    platform: Platform,
224    requirements: &WorkloadRequirements,
225    scale: &CapacityGroupScalePolicy,
226    requires_failure_domain: bool,
227) -> Result<ComputePoolSelection, ErrorData> {
228    let machine = match platform {
229        Platform::Aws | Platform::Gcp | Platform::Azure => Some(
230            instance_catalog::select_instance_type(platform, requirements)
231                .map_err(|message| {
232                    AlienError::new(ErrorData::GenericError {
233                        message: format!("Failed to select {platform} machine: {message}"),
234                    })
235                })?
236                .instance_type
237                .to_string(),
238        ),
239        Platform::Local | Platform::Kubernetes | Platform::Machines | Platform::Test => None,
240    };
241
242    let failure_domains = (requires_failure_domain
243        && matches!(platform, Platform::Aws | Platform::Gcp | Platform::Azure))
244    .then_some(FailureDomainSelection {
245        spread: 1,
246        selected_failure_domains: Vec::new(),
247    });
248
249    match scale {
250        CapacityGroupScalePolicy::Fixed { machines } => Ok(ComputePoolSelection::Fixed {
251            machines: machines.default.max(1),
252            machine,
253            failure_domains,
254        }),
255        CapacityGroupScalePolicy::Autoscale { min, max } => Ok(ComputePoolSelection::Autoscale {
256            min: min.default,
257            max: max.default.max(min.default),
258            machine,
259            failure_domains,
260        }),
261    }
262}
263
264/// Validate one selected compute pool against platform machine requirements and
265/// source-declared scale bounds.
266pub fn validate_compute_pool_selection(
267    platform: Platform,
268    pool_id: &str,
269    selection: &ComputePoolSelection,
270    requirements: &WorkloadRequirements,
271    scale: &CapacityGroupScalePolicy,
272) -> Vec<String> {
273    let mut errors = Vec::new();
274    if let Err(message) = selection.validate() {
275        errors.push(message);
276    }
277    if let Err(message) = validate_selection_against_scale(selection, scale) {
278        errors.push(format!("Pool '{pool_id}' {message}"));
279    }
280    if matches!(platform, Platform::Aws | Platform::Gcp | Platform::Azure) {
281        match selection.machine() {
282            Some(machine) => match instance_catalog::find_instance_type(platform, machine) {
283                Some(spec) => {
284                    let architecture = requirements.architecture.unwrap_or(spec.architecture);
285                    if !instance_satisfies(spec, requirements, architecture) {
286                        errors.push(format!(
287                            "{} machine '{}' does not satisfy pool '{}' requirements",
288                            platform, machine, pool_id
289                        ));
290                    }
291                }
292                None => errors.push(format!(
293                    "Unknown {} machine '{}' for pool '{}'",
294                    platform, machine, pool_id
295                )),
296            },
297            None => errors.push(format!(
298                "Pool '{}' requires a provider machine on {}",
299                pool_id, platform
300            )),
301        }
302    }
303    errors
304}
305
306/// Convert a capacity group declaration into planner requirements.
307pub fn capacity_group_requirements(group: &CapacityGroup) -> WorkloadRequirements {
308    profile_to_requirements(
309        group.profile.as_ref(),
310        group.nested_virtualization.unwrap_or(false),
311    )
312}
313
314fn machine_options(
315    platform: Platform,
316    requirements: &WorkloadRequirements,
317    selected_machine: Option<&str>,
318) -> Result<Vec<ComputeMachineOption>, ErrorData> {
319    if !matches!(platform, Platform::Aws | Platform::Gcp | Platform::Azure) {
320        return Ok(Vec::new());
321    }
322    let recommended =
323        instance_catalog::select_instance_type(platform, requirements).map_err(|message| {
324            AlienError::new(ErrorData::GenericError {
325                message: format!("Failed to select {platform} machine: {message}"),
326            })
327        })?;
328    let resolved_architecture = requirements
329        .architecture
330        .or_else(|| {
331            selected_machine.and_then(|machine| {
332                instance_catalog::find_instance_type(platform, machine)
333                    .map(|spec| spec.architecture)
334            })
335        })
336        .or(recommended.profile.architecture)
337        .ok_or_else(|| {
338            AlienError::new(ErrorData::GenericError {
339                message: format!("Selected {platform} machine has no CPU architecture"),
340            })
341        })?;
342    let recommended = recommended.instance_type.to_string();
343
344    let mut options: Vec<ComputeMachineOption> = instance_catalog::catalog_for_platform(platform)
345        .into_iter()
346        .filter(|spec| instance_satisfies(spec, requirements, resolved_architecture))
347        .map(|spec| ComputeMachineOption {
348            machine: spec.name.to_string(),
349            profile: spec.to_machine_profile(),
350            recommended: spec.name == recommended || Some(spec.name) == selected_machine,
351        })
352        .collect();
353    options.sort_by(|a, b| a.machine.cmp(&b.machine));
354    Ok(options)
355}
356
357fn instance_satisfies(
358    spec: &instance_catalog::InstanceTypeSpec,
359    requirements: &WorkloadRequirements,
360    resolved_architecture: Architecture,
361) -> bool {
362    if spec.architecture != resolved_architecture {
363        return false;
364    }
365    if requirements.nested_virt && !spec.is_nested_virt_capable() {
366        return false;
367    }
368    if spec.vcpu < requirements.max_cpu_per_container.ceil() as u32 {
369        return false;
370    }
371    if spec.memory_bytes < requirements.max_memory_per_container {
372        return false;
373    }
374    if spec.ephemeral_storage_bytes < requirements.max_ephemeral_storage_bytes {
375        return false;
376    }
377    match (&requirements.gpu, spec.gpu) {
378        (Some(required), Some(actual)) => {
379            (required.gpu_type == "any" || required.gpu_type == actual.gpu_type)
380                && actual.count >= required.count
381        }
382        (Some(_), None) => false,
383        (None, _) => true,
384    }
385}
386
387#[derive(Debug, Clone)]
388struct Workload {
389    id: String,
390    cpu: f64,
391    memory_bytes: u64,
392    desired_replicas: f64,
393    max_replicas: f64,
394    ephemeral_storage_bytes: u64,
395    gpu: Option<GpuSpec>,
396    requires_failure_domain: bool,
397}
398
399impl Workload {
400    fn from_container(container: &Container) -> Result<Self, ErrorData> {
401        let cpu = parse_cpu(&container.id, &container.cpu)?;
402        let memory_bytes = parse_memory(&container.id, &container.memory)?;
403        let desired_replicas = container
404            .autoscaling
405            .as_ref()
406            .map(|a| a.desired)
407            .or(container.replicas)
408            .unwrap_or(1) as f64;
409        let max_replicas = container
410            .autoscaling
411            .as_ref()
412            .map(|a| a.max)
413            .or(container.replicas)
414            .unwrap_or(1) as f64;
415        let ephemeral_storage_bytes = container
416            .ephemeral_storage
417            .as_deref()
418            .map(instance_catalog::parse_memory_bytes)
419            .transpose()
420            .map_err(|message| {
421                AlienError::new(ErrorData::GenericError {
422                    message: format!(
423                        "Failed to parse ephemeral storage for '{}': {message}",
424                        container.id
425                    ),
426                })
427            })?
428            .unwrap_or(0);
429
430        Ok(Self {
431            id: container.id.clone(),
432            cpu,
433            memory_bytes,
434            desired_replicas,
435            max_replicas,
436            ephemeral_storage_bytes,
437            gpu: container.gpu.as_ref().map(|gpu| GpuSpec {
438                gpu_type: gpu.gpu_type.clone(),
439                count: gpu.count,
440            }),
441            requires_failure_domain: container.stateful && container.persistent_storage.is_some(),
442        })
443    }
444
445    fn from_daemon(daemon: &Daemon) -> Result<Self, ErrorData> {
446        Ok(Self {
447            id: daemon.id.clone(),
448            cpu: parse_cpu(&daemon.id, &daemon.cpu)?,
449            memory_bytes: parse_memory(&daemon.id, &daemon.memory)?,
450            desired_replicas: 1.0,
451            max_replicas: 1.0,
452            ephemeral_storage_bytes: 0,
453            gpu: None,
454            requires_failure_domain: false,
455        })
456    }
457}
458
459fn parse_cpu(resource_id: &str, spec: &ResourceSpec) -> Result<f64, ErrorData> {
460    instance_catalog::parse_cpu(&spec.desired).map_err(|message| {
461        AlienError::new(ErrorData::GenericError {
462            message: format!(
463                "Failed to parse CPU requirement '{}' for '{}': {message}",
464                spec.desired, resource_id
465            ),
466        })
467    })
468}
469
470fn parse_memory(resource_id: &str, spec: &ResourceSpec) -> Result<u64, ErrorData> {
471    instance_catalog::parse_memory_bytes(&spec.desired).map_err(|message| {
472        AlienError::new(ErrorData::GenericError {
473            message: format!(
474                "Failed to parse memory requirement '{}' for '{}': {message}",
475                spec.desired, resource_id
476            ),
477        })
478    })
479}
480
481fn aggregate_workloads(workloads: &[Workload]) -> WorkloadRequirements {
482    let mut requirements = default_requirements();
483    requirements.total_cpu_at_desired = 0.0;
484    requirements.total_memory_bytes_at_desired = 0;
485    requirements.total_cpu_at_max = 0.0;
486    requirements.total_memory_bytes_at_max = 0;
487    requirements.max_cpu_per_container = 0.0;
488    requirements.max_memory_per_container = 0;
489    requirements.max_ephemeral_storage_bytes = 0;
490    requirements.gpu = None;
491
492    for workload in workloads {
493        requirements.total_cpu_at_desired += workload.cpu * workload.desired_replicas;
494        requirements.total_cpu_at_max += workload.cpu * workload.max_replicas;
495        requirements.total_memory_bytes_at_desired +=
496            (workload.memory_bytes as f64 * workload.desired_replicas) as u64;
497        requirements.total_memory_bytes_at_max +=
498            (workload.memory_bytes as f64 * workload.max_replicas) as u64;
499        requirements.max_cpu_per_container = requirements.max_cpu_per_container.max(workload.cpu);
500        requirements.max_memory_per_container = requirements
501            .max_memory_per_container
502            .max(workload.memory_bytes);
503        requirements.max_ephemeral_storage_bytes = requirements
504            .max_ephemeral_storage_bytes
505            .max(workload.ephemeral_storage_bytes);
506        if requirements.gpu.is_none() {
507            requirements.gpu = workload.gpu.clone();
508        }
509    }
510    requirements
511}
512
513fn default_requirements() -> WorkloadRequirements {
514    WorkloadRequirements {
515        total_cpu_at_desired: 1.0,
516        total_memory_bytes_at_desired: 2 * 1024 * 1024 * 1024,
517        total_cpu_at_max: 1.0,
518        total_memory_bytes_at_max: 2 * 1024 * 1024 * 1024,
519        max_cpu_per_container: 1.0,
520        max_memory_per_container: 2 * 1024 * 1024 * 1024,
521        max_ephemeral_storage_bytes: 0,
522        gpu: None,
523        architecture: None,
524        nested_virt: false,
525    }
526}
527
528fn profile_to_requirements(
529    profile: Option<&MachineProfile>,
530    nested_virt: bool,
531) -> WorkloadRequirements {
532    let Some(profile) = profile else {
533        return WorkloadRequirements {
534            nested_virt,
535            ..default_requirements()
536        };
537    };
538    let cpu = instance_catalog::parse_cpu(&profile.cpu).unwrap_or(1.0);
539    WorkloadRequirements {
540        total_cpu_at_desired: cpu,
541        total_memory_bytes_at_desired: profile.memory_bytes,
542        total_cpu_at_max: cpu,
543        total_memory_bytes_at_max: profile.memory_bytes,
544        max_cpu_per_container: cpu,
545        max_memory_per_container: profile.memory_bytes,
546        max_ephemeral_storage_bytes: profile.ephemeral_storage_bytes,
547        gpu: profile.gpu.clone(),
548        architecture: profile.architecture,
549        nested_virt,
550    }
551}
552
553fn merge_requirements(existing: &mut WorkloadRequirements, declared: &WorkloadRequirements) {
554    existing.total_cpu_at_desired = existing
555        .total_cpu_at_desired
556        .max(declared.total_cpu_at_desired);
557    existing.total_memory_bytes_at_desired = existing
558        .total_memory_bytes_at_desired
559        .max(declared.total_memory_bytes_at_desired);
560    existing.total_cpu_at_max = existing.total_cpu_at_max.max(declared.total_cpu_at_max);
561    existing.total_memory_bytes_at_max = existing
562        .total_memory_bytes_at_max
563        .max(declared.total_memory_bytes_at_max);
564    existing.max_cpu_per_container = existing
565        .max_cpu_per_container
566        .max(declared.max_cpu_per_container);
567    existing.max_memory_per_container = existing
568        .max_memory_per_container
569        .max(declared.max_memory_per_container);
570    existing.max_ephemeral_storage_bytes = existing
571        .max_ephemeral_storage_bytes
572        .max(declared.max_ephemeral_storage_bytes);
573    if existing.gpu.is_none() {
574        existing.gpu = declared.gpu.clone();
575    }
576    existing.nested_virt |= declared.nested_virt;
577    if existing.architecture.is_none() {
578        existing.architecture = declared.architecture;
579    }
580}
581
582fn requirements_to_profile(requirements: &WorkloadRequirements) -> MachineProfile {
583    MachineProfile {
584        cpu: requirements.max_cpu_per_container.to_string(),
585        memory_bytes: requirements.max_memory_per_container,
586        ephemeral_storage_bytes: requirements.max_ephemeral_storage_bytes,
587        architecture: requirements.architecture,
588        gpu: requirements.gpu.clone(),
589    }
590}
591
592fn merge_scale_policy(
593    existing: &CapacityGroupScalePolicy,
594    declared: &CapacityGroupScalePolicy,
595) -> CapacityGroupScalePolicy {
596    match (existing, declared) {
597        (
598            CapacityGroupScalePolicy::Fixed {
599                machines: existing_machines,
600            },
601            CapacityGroupScalePolicy::Fixed {
602                machines: declared_machines,
603            },
604        ) => CapacityGroupScalePolicy::Fixed {
605            machines: merge_choice_range(existing_machines, declared_machines),
606        },
607        (_, declared) => declared.clone(),
608    }
609}
610
611fn merge_choice_range(
612    existing: &ComputeChoiceRange,
613    declared: &ComputeChoiceRange,
614) -> ComputeChoiceRange {
615    ComputeChoiceRange {
616        min: existing.min.max(declared.min),
617        max: existing.max.max(declared.max),
618        default: declared.default,
619    }
620}
621
622fn validate_selection_against_scale(
623    selection: &ComputePoolSelection,
624    scale: &CapacityGroupScalePolicy,
625) -> std::result::Result<(), String> {
626    match (selection, scale) {
627        (
628            ComputePoolSelection::Fixed { machines, .. },
629            CapacityGroupScalePolicy::Fixed { machines: allowed },
630        ) => {
631            if allowed.contains(*machines) {
632                Ok(())
633            } else {
634                Err(format!(
635                    "fixed machine count {machines} is outside the allowed range {}-{}",
636                    allowed.min, allowed.max
637                ))
638            }
639        }
640        (
641            ComputePoolSelection::Autoscale { min, max, .. },
642            CapacityGroupScalePolicy::Autoscale {
643                min: allowed_min,
644                max: allowed_max,
645            },
646        ) => {
647            if !allowed_min.contains(*min) {
648                return Err(format!(
649                    "autoscale minimum {min} is outside the allowed range {}-{}",
650                    allowed_min.min, allowed_min.max
651                ));
652            }
653            if !allowed_max.contains(*max) {
654                return Err(format!(
655                    "autoscale maximum {max} is outside the allowed range {}-{}",
656                    allowed_max.min, allowed_max.max
657                ));
658            }
659            Ok(())
660        }
661        (ComputePoolSelection::Fixed { .. }, CapacityGroupScalePolicy::Autoscale { .. }) => {
662            Err("must use autoscale mode".to_string())
663        }
664        (ComputePoolSelection::Autoscale { .. }, CapacityGroupScalePolicy::Fixed { .. }) => {
665            Err("must use fixed mode".to_string())
666        }
667    }
668}
669
670fn needed_container_pool(container: &Container) -> &'static str {
671    if container.stateful && container.persistent_storage.is_some() {
672        return "stateful";
673    }
674    if container.gpu.is_some() {
675        return "gpu";
676    }
677    if let Some(storage) = &container.ephemeral_storage {
678        if instance_catalog::parse_memory_bytes(storage).unwrap_or(0) > 200 * 1024 * 1024 * 1024 {
679            return "storage";
680        }
681    }
682    "general"
683}
684
685fn default_min_machines(requirements: &WorkloadRequirements) -> u32 {
686    if requirements.total_cpu_at_desired > 0.0 || requirements.total_memory_bytes_at_desired > 0 {
687        1
688    } else {
689        0
690    }
691}
692
693fn default_max_machines(requirements: &WorkloadRequirements) -> u32 {
694    let min = default_min_machines(requirements);
695    let by_cpu =
696        (requirements.total_cpu_at_max / requirements.max_cpu_per_container.max(1.0)).ceil() as u32;
697    let by_mem = requirements
698        .total_memory_bytes_at_max
699        .div_ceil(requirements.max_memory_per_container.max(1)) as u32;
700    min.max(by_cpu).max(by_mem).max(1)
701}
702
703#[cfg(test)]
704mod tests {
705    use super::*;
706    use crate::{
707        instance_catalog::Architecture, CapacityGroup, CapacityGroupScalePolicy,
708        ComputeChoiceRange, ComputeCluster, ComputeSettings, ContainerCode, DaemonCode, Resource,
709        ResourceEntry, ResourceLifecycle, Stack,
710    };
711
712    fn stack_with_container() -> Stack {
713        let container = Container::new("api".to_string())
714            .code(ContainerCode::Image {
715                image: "api:latest".to_string(),
716            })
717            .cpu(ResourceSpec {
718                min: "1".to_string(),
719                desired: "2".to_string(),
720            })
721            .memory(ResourceSpec {
722                min: "2Gi".to_string(),
723                desired: "4Gi".to_string(),
724            })
725            .permissions("api".to_string())
726            .build();
727        Stack {
728            id: "test".to_string(),
729            resources: [(
730                "api".to_string(),
731                ResourceEntry {
732                    config: Resource::new(container),
733                    lifecycle: ResourceLifecycle::Live,
734                    dependencies: Vec::new(),
735                    remote_access: false,
736                },
737            )]
738            .into_iter()
739            .collect(),
740            permissions: crate::permissions::PermissionsConfig::default(),
741            supported_platforms: None,
742            inputs: vec![],
743        }
744    }
745
746    #[test]
747    fn cloud_plan_recommends_provider_machine_without_mutating_selection() {
748        let stack = stack_with_container();
749
750        let plan = plan_compute(&stack, Platform::Aws, None).expect("plan should build");
751
752        let pool = plan.pools.first().expect("general pool should exist");
753        assert_eq!(pool.pool_id, "general");
754        assert_eq!(pool.workloads, vec!["api"]);
755        assert!(pool.selected.machine().is_some());
756        assert!(pool.machines.iter().any(|machine| machine.recommended));
757    }
758
759    #[test]
760    fn cloud_plan_only_offers_machines_matching_the_default_image_target() {
761        let stack = stack_with_container();
762
763        for platform in [Platform::Aws, Platform::Gcp, Platform::Azure] {
764            let plan = plan_compute(&stack, platform, None).expect("plan should build");
765            let pool = plan.pools.first().expect("general pool should exist");
766            let expected = instance_catalog::default_architecture(platform)
767                .expect("managed cloud should have a default architecture");
768
769            assert!(pool.machines.iter().all(|machine| {
770                instance_catalog::find_instance_type(platform, &machine.machine)
771                    .is_some_and(|spec| spec.architecture == expected)
772            }));
773        }
774    }
775
776    #[test]
777    fn selected_machine_is_preserved_as_static_deployment_choice() {
778        let stack = stack_with_container();
779        let settings = ComputeSettings {
780            pools: [(
781                "general".to_string(),
782                ComputePoolSelection::Fixed {
783                    machines: 1,
784                    machine: Some("m7g.xlarge".to_string()),
785                    failure_domains: None,
786                },
787            )]
788            .into_iter()
789            .collect(),
790        };
791
792        let plan = plan_compute(&stack, Platform::Aws, Some(&settings)).expect("plan should build");
793
794        let pool = plan.pools.first().expect("general pool should exist");
795        assert_eq!(pool.selected.machine(), Some("m7g.xlarge"));
796        assert!(pool.errors.is_empty());
797    }
798
799    #[test]
800    fn selected_machine_defines_architecture_when_workloads_do_not() {
801        let stack = stack_with_container();
802        let settings = ComputeSettings {
803            pools: [(
804                "general".to_string(),
805                ComputePoolSelection::Fixed {
806                    machines: 1,
807                    machine: Some("m7i.xlarge".to_string()),
808                    failure_domains: None,
809                },
810            )]
811            .into_iter()
812            .collect(),
813        };
814
815        let plan = plan_compute(&stack, Platform::Aws, Some(&settings)).expect("plan should build");
816
817        let pool = plan.pools.first().expect("general pool should exist");
818        assert!(pool.errors.is_empty());
819        assert!(pool.machines.iter().all(|machine| {
820            instance_catalog::find_instance_type(Platform::Aws, &machine.machine)
821                .is_some_and(|spec| spec.architecture == Architecture::X86_64)
822        }));
823    }
824
825    #[test]
826    fn explicit_capacity_group_requirements_are_merged_with_workloads() {
827        let mut stack = stack_with_container();
828        let cluster = ComputeCluster::new("compute".to_string())
829            .capacity_group(CapacityGroup {
830                group_id: "general".to_string(),
831                instance_type: None,
832                profile: Some(MachineProfile {
833                    cpu: "4".to_string(),
834                    memory_bytes: 16 * 1024 * 1024 * 1024,
835                    ephemeral_storage_bytes: 20 * 1024 * 1024 * 1024,
836                    architecture: Some(Architecture::X86_64),
837                    gpu: None,
838                }),
839                min_size: 2,
840                max_size: 5,
841                scale_policy: None,
842                nested_virtualization: Some(true),
843            })
844            .build();
845        stack.resources.insert(
846            "compute".to_string(),
847            ResourceEntry {
848                config: Resource::new(cluster),
849                lifecycle: ResourceLifecycle::Frozen,
850                dependencies: Vec::new(),
851                remote_access: false,
852            },
853        );
854
855        let plan = plan_compute(&stack, Platform::Aws, None).expect("plan should build");
856
857        let pool = plan.pools.first().expect("general pool should exist");
858        let machine = pool
859            .selected
860            .machine()
861            .expect("AWS selection should include a machine");
862        let spec = instance_catalog::find_instance_type(Platform::Aws, machine)
863            .expect("selected machine should exist in the catalog");
864        assert!(spec.is_nested_virt_capable());
865        assert_eq!(pool.selected.min_size(), 2);
866        assert_eq!(pool.selected.max_size(), 5);
867        assert!(pool.errors.is_empty());
868    }
869
870    #[test]
871    fn nested_x86_fixed_range_pool_preserves_bounds_and_rejects_graviton() {
872        let daemon = Daemon::new("vm-runtime-loader".to_string())
873            .code(DaemonCode::Image {
874                image: "example.com/vm-runtime:latest".to_string(),
875            })
876            .cluster("vm-runtime".to_string())
877            .cpu(ResourceSpec {
878                min: "2".to_string(),
879                desired: "2".to_string(),
880            })
881            .memory(ResourceSpec {
882                min: "4Gi".to_string(),
883                desired: "4Gi".to_string(),
884            })
885            .permissions("loader".to_string())
886            .build();
887        let cluster = ComputeCluster::new("vm-runtime".to_string())
888            .capacity_group(CapacityGroup {
889                group_id: "general".to_string(),
890                instance_type: None,
891                profile: Some(MachineProfile {
892                    cpu: "4".to_string(),
893                    memory_bytes: 16 * 1024 * 1024 * 1024,
894                    ephemeral_storage_bytes: 20 * 1024 * 1024 * 1024,
895                    architecture: Some(Architecture::X86_64),
896                    gpu: None,
897                }),
898                min_size: 2,
899                max_size: 2,
900                scale_policy: Some(CapacityGroupScalePolicy::Fixed {
901                    machines: ComputeChoiceRange {
902                        min: 1,
903                        max: 5,
904                        default: 2,
905                    },
906                }),
907                nested_virtualization: Some(true),
908            })
909            .build();
910        let stack = Stack {
911            id: "vm-runtime".to_string(),
912            resources: [
913                (
914                    "vm-runtime-loader".to_string(),
915                    ResourceEntry {
916                        config: Resource::new(daemon),
917                        lifecycle: ResourceLifecycle::Live,
918                        dependencies: Vec::new(),
919                        remote_access: false,
920                    },
921                ),
922                (
923                    "vm-runtime".to_string(),
924                    ResourceEntry {
925                        config: Resource::new(cluster),
926                        lifecycle: ResourceLifecycle::Frozen,
927                        dependencies: Vec::new(),
928                        remote_access: false,
929                    },
930                ),
931            ]
932            .into_iter()
933            .collect(),
934            permissions: crate::permissions::PermissionsConfig::default(),
935            supported_platforms: None,
936            inputs: vec![],
937        };
938
939        let plan = plan_compute(&stack, Platform::Aws, None).expect("plan should build");
940        let pool = plan.pools.first().expect("general pool should exist");
941        assert_eq!(pool.recommended.machine(), Some("m8i.2xlarge"));
942        assert_eq!(pool.recommended.min_size(), 2);
943        assert_eq!(pool.recommended.max_size(), 2);
944        assert_eq!(
945            pool.scale,
946            CapacityGroupScalePolicy::Fixed {
947                machines: ComputeChoiceRange {
948                    min: 1,
949                    max: 5,
950                    default: 2,
951                },
952            }
953        );
954        assert!(!pool
955            .machines
956            .iter()
957            .any(|option| option.machine == "m7g.2xlarge"));
958
959        let invalid_settings = ComputeSettings {
960            pools: [(
961                "general".to_string(),
962                ComputePoolSelection::Fixed {
963                    machines: 2,
964                    machine: Some("m7g.2xlarge".to_string()),
965                    failure_domains: None,
966                },
967            )]
968            .into_iter()
969            .collect(),
970        };
971        let invalid_plan = plan_compute(&stack, Platform::Aws, Some(&invalid_settings))
972            .expect("plan should build");
973        assert!(!invalid_plan.pools[0].errors.is_empty());
974    }
975
976    #[test]
977    fn local_plan_has_no_provider_machine_choices() {
978        let stack = stack_with_container();
979
980        let plan = plan_compute(&stack, Platform::Local, None).expect("plan should build");
981
982        let pool = plan.pools.first().expect("general pool should exist");
983        assert_eq!(pool.selected.machine(), None);
984        assert!(pool.machines.is_empty());
985        assert!(pool.errors.is_empty());
986    }
987}