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