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                    remote_access: false,
1777                    enabled_when: None,
1778                },
1779            )]
1780            .into_iter()
1781            .collect(),
1782            permissions: crate::permissions::PermissionsConfig::default(),
1783            supported_platforms: None,
1784            inputs: vec![],
1785            dynamic_container_repositories: Vec::new(),
1786            dynamic_container_image_resources: Vec::new(),
1787            operations: None,
1788        }
1789    }
1790
1791    #[test]
1792    fn serverless_workers_and_storage_do_not_allocate_machine_pools() {
1793        let mut stack = stack_with_container();
1794        stack.resources.clear();
1795        for (id, resource) in [
1796            (
1797                "api",
1798                Resource::new(
1799                    Worker::new("api".to_string())
1800                        .code(WorkerCode::Image {
1801                            image: "example/worker:v1".to_string(),
1802                        })
1803                        .permissions("execution".to_string())
1804                        .build(),
1805                ),
1806            ),
1807            (
1808                "data",
1809                Resource::new(Storage::new("data".to_string()).build()),
1810            ),
1811        ] {
1812            stack.resources.insert(
1813                id.to_string(),
1814                ResourceEntry {
1815                    config: resource,
1816                    lifecycle: ResourceLifecycle::Frozen,
1817                    dependencies: Vec::new(),
1818                    remote_access: false,
1819                    enabled_when: None,
1820                },
1821            );
1822        }
1823
1824        for platform in [Platform::Aws, Platform::Gcp, Platform::Azure] {
1825            let plan = plan_compute(&stack, platform, None).expect("plan should build");
1826            assert!(plan.pools.is_empty(), "serverless plan allocated {plan:?}");
1827        }
1828    }
1829
1830    #[test]
1831    fn empty_stack_does_not_allocate_machine_pools() {
1832        let mut stack = stack_with_container();
1833        stack.resources.clear();
1834        let plan = plan_compute(&stack, Platform::Aws, None).expect("plan should build");
1835        assert!(plan.pools.is_empty());
1836    }
1837
1838    #[test]
1839    fn explicit_cluster_without_workloads_only_plans_declared_pools() {
1840        let mut stack = stack_with_container();
1841        stack.resources.clear();
1842        let cluster = ComputeCluster::new("compute".to_string())
1843            .capacity_group(CapacityGroup {
1844                group_id: "batch".to_string(),
1845                instance_type: Some("t4g.medium".to_string()),
1846                profile: None,
1847                min_size: 1,
1848                max_size: 1,
1849                scale_policy: None,
1850                nested_virtualization: None,
1851            })
1852            .build();
1853        stack.resources.insert(
1854            "compute".to_string(),
1855            ResourceEntry {
1856                config: Resource::new(cluster),
1857                lifecycle: ResourceLifecycle::Frozen,
1858                dependencies: Vec::new(),
1859                remote_access: false,
1860                enabled_when: None,
1861            },
1862        );
1863
1864        let plan = plan_compute(&stack, Platform::Aws, None).expect("plan should build");
1865        assert_eq!(plan.pools.len(), 1);
1866        assert_eq!(plan.pools[0].pool_id, "batch");
1867        assert!(plan.pools[0].errors.is_empty());
1868    }
1869
1870    #[test]
1871    fn cloud_plan_recommends_provider_machine_without_mutating_selection() {
1872        let stack = stack_with_container();
1873
1874        let plan = plan_compute(&stack, Platform::Aws, None).expect("plan should build");
1875
1876        let pool = plan.pools.first().expect("general pool should exist");
1877        assert_eq!(pool.pool_id, "general");
1878        assert_eq!(pool.workloads, vec!["api"]);
1879        assert!(pool.selected.machine().is_some());
1880        assert!(pool.machines.iter().any(|machine| machine.recommended));
1881    }
1882
1883    #[test]
1884    fn cloud_plan_only_offers_machines_matching_the_default_image_target() {
1885        let stack = stack_with_container();
1886
1887        for platform in [Platform::Aws, Platform::Gcp, Platform::Azure] {
1888            let plan = plan_compute(&stack, platform, None).expect("plan should build");
1889            let pool = plan.pools.first().expect("general pool should exist");
1890            let expected = instance_catalog::default_architecture(platform)
1891                .expect("managed cloud should have a default architecture");
1892
1893            assert!(pool.machines.iter().all(|machine| {
1894                instance_catalog::find_instance_type(platform, &machine.machine)
1895                    .is_some_and(|spec| spec.architecture == expected)
1896            }));
1897        }
1898    }
1899
1900    #[test]
1901    fn selected_machine_is_preserved_as_static_deployment_choice() {
1902        let stack = stack_with_container();
1903        let settings = ComputeSettings {
1904            containers: Default::default(),
1905            pools: [(
1906                "general".to_string(),
1907                ComputePoolSelection::Fixed {
1908                    machines: 1,
1909                    machine: Some("m7g.xlarge".to_string()),
1910                    failure_domains: None,
1911                },
1912            )]
1913            .into_iter()
1914            .collect(),
1915        };
1916
1917        let plan = plan_compute(&stack, Platform::Aws, Some(&settings)).expect("plan should build");
1918
1919        let pool = plan.pools.first().expect("general pool should exist");
1920        assert_eq!(pool.selected.machine(), Some("m7g.xlarge"));
1921        assert!(pool.errors.is_empty());
1922    }
1923
1924    #[test]
1925    fn configurable_cloud_disk_satisfies_storage_above_fixed_local_catalog() {
1926        let mut stack = stack_with_container();
1927        let container = stack
1928            .resources
1929            .get_mut("api")
1930            .and_then(|entry| entry.config.downcast_mut::<Container>())
1931            .expect("test stack should contain a container");
1932        container.ephemeral_storage = Some("8000Gi".to_string());
1933        let settings = ComputeSettings {
1934            containers: Default::default(),
1935            pools: [(
1936                "storage".to_string(),
1937                ComputePoolSelection::Fixed {
1938                    machines: 1,
1939                    machine: Some("m8i.2xlarge".to_string()),
1940                    failure_domains: None,
1941                },
1942            )]
1943            .into_iter()
1944            .collect(),
1945        };
1946
1947        let plan = plan_compute(&stack, Platform::Aws, Some(&settings)).expect("plan should build");
1948        let pool = plan.pools.first().expect("general pool should exist");
1949        assert!(pool.errors.is_empty());
1950        let selected = pool
1951            .machines
1952            .iter()
1953            .find(|option| option.machine == "m8i.2xlarge")
1954            .expect("selected machine should remain an option");
1955        assert_eq!(
1956            selected.profile.ephemeral_storage_bytes,
1957            8_000 * 1024 * 1024 * 1024
1958        );
1959    }
1960
1961    #[test]
1962    fn selected_machine_defines_architecture_when_workloads_do_not() {
1963        let stack = stack_with_container();
1964        let settings = ComputeSettings {
1965            containers: Default::default(),
1966            pools: [(
1967                "general".to_string(),
1968                ComputePoolSelection::Fixed {
1969                    machines: 1,
1970                    machine: Some("m7i.xlarge".to_string()),
1971                    failure_domains: None,
1972                },
1973            )]
1974            .into_iter()
1975            .collect(),
1976        };
1977
1978        let plan = plan_compute(&stack, Platform::Aws, Some(&settings)).expect("plan should build");
1979
1980        let pool = plan.pools.first().expect("general pool should exist");
1981        assert!(pool.errors.is_empty());
1982        assert!(pool.machines.iter().all(|machine| {
1983            instance_catalog::find_instance_type(Platform::Aws, &machine.machine)
1984                .is_some_and(|spec| spec.architecture == Architecture::X86_64)
1985        }));
1986    }
1987
1988    #[test]
1989    fn graviton4_compute_machine_can_be_selected() {
1990        let stack = stack_with_container();
1991        let settings = ComputeSettings {
1992            containers: Default::default(),
1993            pools: [(
1994                "general".to_string(),
1995                ComputePoolSelection::Fixed {
1996                    machines: 1,
1997                    machine: Some("c8g.2xlarge".to_string()),
1998                    failure_domains: None,
1999                },
2000            )]
2001            .into_iter()
2002            .collect(),
2003        };
2004
2005        let plan = plan_compute(&stack, Platform::Aws, Some(&settings)).expect("plan should build");
2006
2007        let pool = plan.pools.first().expect("general pool should exist");
2008        assert!(pool.errors.is_empty(), "{:?}", pool.errors);
2009        let selected = pool
2010            .machines
2011            .iter()
2012            .find(|option| option.machine == "c8g.2xlarge")
2013            .expect("selected machine should be offered");
2014        assert!(selected.recommended);
2015        assert_eq!(selected.profile.architecture, Some(Architecture::Arm64));
2016    }
2017
2018    #[test]
2019    fn explicit_capacity_group_requirements_are_merged_with_workloads() {
2020        let mut stack = stack_with_container();
2021        let cluster = ComputeCluster::new("compute".to_string())
2022            .capacity_group(CapacityGroup {
2023                group_id: "general".to_string(),
2024                instance_type: None,
2025                profile: Some(MachineProfile {
2026                    cpu: "4".to_string(),
2027                    memory_bytes: 16 * 1024 * 1024 * 1024,
2028                    ephemeral_storage_bytes: 20 * 1024 * 1024 * 1024,
2029                    architecture: Some(Architecture::X86_64),
2030                    gpu: None,
2031                    data_disk: None,
2032                }),
2033                min_size: 2,
2034                max_size: 5,
2035                scale_policy: None,
2036                nested_virtualization: Some(true),
2037            })
2038            .build();
2039        stack.resources.insert(
2040            "compute".to_string(),
2041            ResourceEntry {
2042                config: Resource::new(cluster),
2043                lifecycle: ResourceLifecycle::Frozen,
2044                dependencies: Vec::new(),
2045                remote_access: false,
2046                enabled_when: None,
2047            },
2048        );
2049
2050        let plan = plan_compute(&stack, Platform::Aws, None).expect("plan should build");
2051
2052        let pool = plan.pools.first().expect("general pool should exist");
2053        let machine = pool
2054            .selected
2055            .machine()
2056            .expect("AWS selection should include a machine");
2057        let spec = instance_catalog::find_instance_type(Platform::Aws, machine)
2058            .expect("selected machine should exist in the catalog");
2059        assert!(spec.is_nested_virt_capable());
2060        assert_eq!(pool.selected.min_size(), 2);
2061        assert_eq!(pool.selected.max_size(), 5);
2062        assert!(pool.errors.is_empty());
2063    }
2064
2065    #[test]
2066    fn materialized_machine_profile_is_not_counted_as_workload_demand() {
2067        let mut stack = stack_with_container();
2068        let selected = instance_catalog::find_instance_type(Platform::Aws, "m7g.xlarge")
2069            .expect("machine should exist in catalog");
2070        let cluster = ComputeCluster::new("compute".to_string())
2071            .capacity_group(CapacityGroup {
2072                group_id: "general".to_string(),
2073                instance_type: Some(selected.name.to_string()),
2074                profile: Some(selected.to_machine_profile()),
2075                min_size: 1,
2076                max_size: 1,
2077                scale_policy: None,
2078                nested_virtualization: None,
2079            })
2080            .build();
2081        stack.resources.insert(
2082            "compute".to_string(),
2083            ResourceEntry {
2084                config: Resource::new(cluster),
2085                lifecycle: ResourceLifecycle::Frozen,
2086                dependencies: Vec::new(),
2087                remote_access: false,
2088                enabled_when: None,
2089            },
2090        );
2091
2092        let plan = plan_compute(&stack, Platform::Aws, None).expect("plan should build");
2093        let pool = plan.pools.first().expect("general pool should exist");
2094
2095        assert_eq!(pool.requirements.cpu, "2");
2096        assert_eq!(pool.requirements.memory_bytes, 4 * 1024 * 1024 * 1024);
2097        assert_eq!(pool.recommended.machine(), Some("m7g.xlarge"));
2098        assert!(pool
2099            .machines
2100            .iter()
2101            .any(|option| option.machine == "m7g.xlarge"));
2102    }
2103
2104    #[test]
2105    fn nested_x86_fixed_range_pool_preserves_bounds_and_rejects_graviton() {
2106        let daemon = Daemon::new("vm-runtime-loader".to_string())
2107            .code(DaemonCode::Image {
2108                image: "example.com/vm-runtime:latest".to_string(),
2109            })
2110            .cluster("vm-runtime".to_string())
2111            .cpu(ResourceSpec {
2112                min: "2".to_string(),
2113                desired: "2".to_string(),
2114            })
2115            .memory(ResourceSpec {
2116                min: "4Gi".to_string(),
2117                desired: "4Gi".to_string(),
2118            })
2119            .permissions("loader".to_string())
2120            .build();
2121        let cluster = ComputeCluster::new("vm-runtime".to_string())
2122            .capacity_group(CapacityGroup {
2123                group_id: "general".to_string(),
2124                instance_type: None,
2125                profile: Some(MachineProfile {
2126                    cpu: "4".to_string(),
2127                    memory_bytes: 16 * 1024 * 1024 * 1024,
2128                    ephemeral_storage_bytes: 20 * 1024 * 1024 * 1024,
2129                    architecture: Some(Architecture::X86_64),
2130                    gpu: None,
2131                    data_disk: None,
2132                }),
2133                min_size: 2,
2134                max_size: 2,
2135                scale_policy: Some(CapacityGroupScalePolicy::Fixed {
2136                    machines: ComputeChoiceRange {
2137                        min: 1,
2138                        max: 5,
2139                        default: 2,
2140                    },
2141                }),
2142                nested_virtualization: Some(true),
2143            })
2144            .build();
2145        let stack = Stack {
2146            id: "vm-runtime".to_string(),
2147            resources: [
2148                (
2149                    "vm-runtime-loader".to_string(),
2150                    ResourceEntry {
2151                        config: Resource::new(daemon),
2152                        lifecycle: ResourceLifecycle::Live,
2153                        dependencies: Vec::new(),
2154                        remote_access: false,
2155                        enabled_when: None,
2156                    },
2157                ),
2158                (
2159                    "vm-runtime".to_string(),
2160                    ResourceEntry {
2161                        config: Resource::new(cluster),
2162                        lifecycle: ResourceLifecycle::Frozen,
2163                        dependencies: Vec::new(),
2164                        remote_access: false,
2165                        enabled_when: None,
2166                    },
2167                ),
2168            ]
2169            .into_iter()
2170            .collect(),
2171            permissions: crate::permissions::PermissionsConfig::default(),
2172            supported_platforms: None,
2173            inputs: vec![],
2174            dynamic_container_repositories: Vec::new(),
2175            dynamic_container_image_resources: Vec::new(),
2176            operations: None,
2177        };
2178
2179        let plan = plan_compute(&stack, Platform::Aws, None).expect("plan should build");
2180        let pool = plan.pools.first().expect("general pool should exist");
2181        assert_eq!(pool.recommended.machine(), Some("m8i.2xlarge"));
2182        assert_eq!(pool.recommended.min_size(), 2);
2183        assert_eq!(pool.recommended.max_size(), 2);
2184        assert_eq!(
2185            pool.scale,
2186            CapacityGroupScalePolicy::Fixed {
2187                machines: ComputeChoiceRange {
2188                    min: 1,
2189                    max: 5,
2190                    default: 2,
2191                },
2192            }
2193        );
2194        assert!(!pool
2195            .machines
2196            .iter()
2197            .any(|option| option.machine == "m7g.2xlarge"));
2198
2199        let invalid_settings = ComputeSettings {
2200            containers: Default::default(),
2201            pools: [(
2202                "general".to_string(),
2203                ComputePoolSelection::Fixed {
2204                    machines: 2,
2205                    machine: Some("m7g.2xlarge".to_string()),
2206                    failure_domains: None,
2207                },
2208            )]
2209            .into_iter()
2210            .collect(),
2211        };
2212        let invalid_plan = plan_compute(&stack, Platform::Aws, Some(&invalid_settings))
2213            .expect("plan should build");
2214        assert!(!invalid_plan.pools[0].errors.is_empty());
2215    }
2216
2217    fn declared_pool_errors(
2218        scale: CapacityGroupScalePolicy,
2219        selection: ComputePoolSelection,
2220    ) -> Vec<String> {
2221        let mut stack = stack_with_container();
2222        let cluster = ComputeCluster::new("compute".to_string())
2223            .capacity_group(CapacityGroup {
2224                group_id: "general".to_string(),
2225                instance_type: None,
2226                profile: None,
2227                min_size: scale.default_min_size(),
2228                max_size: scale.default_max_size(),
2229                scale_policy: Some(scale),
2230                nested_virtualization: None,
2231            })
2232            .build();
2233        stack.resources.insert(
2234            "compute".to_string(),
2235            ResourceEntry {
2236                config: Resource::new(cluster),
2237                lifecycle: ResourceLifecycle::Frozen,
2238                dependencies: Vec::new(),
2239                remote_access: false,
2240                enabled_when: None,
2241            },
2242        );
2243        let settings = ComputeSettings {
2244            containers: Default::default(),
2245            pools: [("general".to_string(), selection)].into_iter().collect(),
2246        };
2247        plan_compute(&stack, Platform::Aws, Some(&settings))
2248            .expect("plan should build")
2249            .pools
2250            .remove(0)
2251            .errors
2252    }
2253
2254    fn range(min: u32, max: u32, default: u32) -> ComputeChoiceRange {
2255        ComputeChoiceRange { min, max, default }
2256    }
2257
2258    #[test]
2259    fn generated_pool_offers_the_recommendation_as_a_default_not_a_bound() {
2260        let mut stack = stack_with_container();
2261        let api = stack
2262            .resources
2263            .get_mut("api")
2264            .and_then(|entry| entry.config.downcast_mut::<Container>())
2265            .expect("test stack should contain a container");
2266        api.autoscaling = Some(crate::ContainerAutoscaling {
2267            min: 1,
2268            desired: 1,
2269            max: 3,
2270            target_cpu_percent: Some(70.0),
2271            target_memory_percent: None,
2272            target_http_in_flight_per_replica: None,
2273            max_http_p95_latency_ms: None,
2274        });
2275
2276        let plan = plan_compute(&stack, Platform::Aws, None).expect("plan should build");
2277        let pool = &plan.pools[0];
2278        assert_eq!(
2279            pool.scale,
2280            CapacityGroupScalePolicy::Autoscale {
2281                min: range(1, 100, 1),
2282                max: range(1, 100, 3),
2283            }
2284        );
2285        assert_eq!(
2286            (pool.recommended.min_size(), pool.recommended.max_size()),
2287            (1, 3)
2288        );
2289
2290        // Workloads that never scale get a fixed recommendation, but either mode stays
2291        // allowed: replica counts change between releases while the saved choice stays.
2292        let plan =
2293            plan_compute(&stack_with_container(), Platform::Aws, None).expect("plan should build");
2294        assert_eq!(
2295            plan.pools[0].scale,
2296            CapacityGroupScalePolicy::Autoscale {
2297                min: range(1, 100, 1),
2298                max: range(1, 100, 1),
2299            }
2300        );
2301        assert!(matches!(
2302            plan.pools[0].recommended,
2303            ComputePoolSelection::Fixed { machines: 1, .. }
2304        ));
2305        assert!(plan.pools[0].errors.is_empty());
2306    }
2307
2308    #[test]
2309    fn generated_pool_rejects_a_fleet_too_small_for_desired_replicas() {
2310        let mut stack = stack_with_container();
2311        let api = stack
2312            .resources
2313            .get_mut("api")
2314            .and_then(|entry| entry.config.downcast_mut::<Container>())
2315            .expect("test stack should contain a container");
2316        // 3 x 2 vCPU / 4 GiB requested at the desired count.
2317        api.replicas = Some(3);
2318        let selection = |machines| ComputeSettings {
2319            containers: Default::default(),
2320            pools: [(
2321                "general".to_string(),
2322                ComputePoolSelection::Fixed {
2323                    machines,
2324                    machine: Some("m7g.xlarge".to_string()),
2325                    failure_domains: None,
2326                },
2327            )]
2328            .into_iter()
2329            .collect(),
2330        };
2331
2332        // m7g.xlarge: 3.5 vCPU available after the host reserve.
2333        let errors = plan_compute(&stack, Platform::Aws, Some(&selection(1)))
2334            .expect("plan should build")
2335            .pools
2336            .remove(0)
2337            .errors;
2338        assert_eq!(errors.len(), 1, "{errors:?}");
2339        assert!(
2340            errors[0].starts_with(
2341                "Pool 'general' is too small for its workloads: 1 x m7g.xlarge has 3.50 vCPU"
2342            ),
2343            "{errors:?}"
2344        );
2345        assert!(errors[0].contains("request 6.00 vCPU"), "{errors:?}");
2346
2347        let errors = plan_compute(&stack, Platform::Aws, Some(&selection(2)))
2348            .expect("plan should build")
2349            .pools
2350            .remove(0)
2351            .errors;
2352        assert!(errors.is_empty(), "{errors:?}");
2353    }
2354
2355    #[test]
2356    fn fixed_and_equal_autoscale_bounds_are_interchangeable_for_declared_pools() {
2357        let fixed = |machines| ComputePoolSelection::Fixed {
2358            machines,
2359            machine: Some("m7g.xlarge".to_string()),
2360            failure_domains: None,
2361        };
2362        let autoscale = |min, max| ComputePoolSelection::Autoscale {
2363            min,
2364            max,
2365            machine: Some("m7g.xlarge".to_string()),
2366            failure_domains: None,
2367        };
2368        let declared_fixed = CapacityGroupScalePolicy::Fixed {
2369            machines: range(2, 4, 2),
2370        };
2371        let declared_autoscale = CapacityGroupScalePolicy::Autoscale {
2372            min: range(1, 3, 1),
2373            max: range(2, 6, 4),
2374        };
2375
2376        assert!(declared_pool_errors(declared_fixed.clone(), autoscale(3, 3)).is_empty());
2377        assert_eq!(
2378            declared_pool_errors(declared_fixed.clone(), autoscale(2, 4)),
2379            vec!["Pool 'general' must use fixed mode with 2-4 machines; autoscale 2-4 is not allowed"]
2380        );
2381        assert_eq!(
2382            declared_pool_errors(declared_fixed, autoscale(5, 5)),
2383            vec!["Pool 'general' machine count 5 is outside the allowed range 2-4"]
2384        );
2385
2386        // Fixed N must fit both the declared minimum and maximum ranges.
2387        assert!(declared_pool_errors(declared_autoscale.clone(), fixed(3)).is_empty());
2388        assert_eq!(
2389            declared_pool_errors(declared_autoscale.clone(), fixed(1)),
2390            vec!["Pool 'general' fixed machine count 1 is outside the allowed range 2-3"]
2391        );
2392        assert_eq!(
2393            declared_pool_errors(
2394                CapacityGroupScalePolicy::Autoscale {
2395                    min: range(1, 1, 1),
2396                    max: range(3, 5, 3),
2397                },
2398                fixed(1),
2399            ),
2400            vec!["Pool 'general' must use autoscale mode (minimum 1-1, maximum 3-5)"]
2401        );
2402        assert_eq!(
2403            declared_pool_errors(declared_autoscale, autoscale(1, 7)),
2404            vec!["Pool 'general' autoscale maximum 7 is outside the allowed range 2-6"]
2405        );
2406    }
2407
2408    #[test]
2409    fn local_plan_has_no_provider_machine_choices() {
2410        let stack = stack_with_container();
2411
2412        let plan = plan_compute(&stack, Platform::Local, None).expect("plan should build");
2413
2414        let pool = plan.pools.first().expect("general pool should exist");
2415        assert_eq!(pool.selected.machine(), None);
2416        assert!(pool.machines.is_empty());
2417        assert!(pool.errors.is_empty());
2418    }
2419}