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    }
975}
976
977fn merge_scale_policy(
978    existing: &CapacityGroupScalePolicy,
979    declared: &CapacityGroupScalePolicy,
980) -> CapacityGroupScalePolicy {
981    match (existing, declared) {
982        (
983            CapacityGroupScalePolicy::Fixed {
984                machines: existing_machines,
985            },
986            CapacityGroupScalePolicy::Fixed {
987                machines: declared_machines,
988            },
989        ) => CapacityGroupScalePolicy::Fixed {
990            machines: merge_choice_range(existing_machines, declared_machines),
991        },
992        (_, declared) => declared.clone(),
993    }
994}
995
996fn merge_choice_range(
997    existing: &ComputeChoiceRange,
998    declared: &ComputeChoiceRange,
999) -> ComputeChoiceRange {
1000    ComputeChoiceRange {
1001        min: existing.min.max(declared.min),
1002        max: existing.max.max(declared.max),
1003        default: declared.default,
1004    }
1005}
1006
1007fn validate_selection_against_scale(
1008    selection: &ComputePoolSelection,
1009    scale: &CapacityGroupScalePolicy,
1010) -> std::result::Result<(), String> {
1011    // A fixed pool of N machines and an autoscaling pool bounded to N..N run the same
1012    // fleet, so either form is accepted wherever the other one is.
1013    match (selection, scale) {
1014        (
1015            ComputePoolSelection::Fixed { machines, .. },
1016            CapacityGroupScalePolicy::Fixed { machines: allowed },
1017        ) => check_machine_count("fixed machine count", *machines, allowed.min, allowed.max),
1018        (
1019            ComputePoolSelection::Autoscale { min, max, .. },
1020            CapacityGroupScalePolicy::Autoscale {
1021                min: allowed_min,
1022                max: allowed_max,
1023            },
1024        ) => {
1025            check_machine_count("autoscale minimum", *min, allowed_min.min, allowed_min.max)?;
1026            check_machine_count("autoscale maximum", *max, allowed_max.min, allowed_max.max)
1027        }
1028        (
1029            ComputePoolSelection::Fixed { machines, .. },
1030            CapacityGroupScalePolicy::Autoscale {
1031                min: allowed_min,
1032                max: allowed_max,
1033            },
1034        ) => {
1035            let lowest = allowed_min.min.max(allowed_max.min);
1036            let highest = allowed_min.max.min(allowed_max.max);
1037            if lowest > highest {
1038                return Err(format!(
1039                    "must use autoscale mode (minimum {}-{}, maximum {}-{})",
1040                    allowed_min.min, allowed_min.max, allowed_max.min, allowed_max.max
1041                ));
1042            }
1043            check_machine_count("fixed machine count", *machines, lowest, highest)
1044        }
1045        (
1046            ComputePoolSelection::Autoscale { min, max, .. },
1047            CapacityGroupScalePolicy::Fixed { machines: allowed },
1048        ) => {
1049            if min != max {
1050                return Err(format!(
1051                    "must use fixed mode with {}-{} machines; autoscale {min}-{max} is not allowed",
1052                    allowed.min, allowed.max
1053                ));
1054            }
1055            check_machine_count("machine count", *min, allowed.min, allowed.max)
1056        }
1057    }
1058}
1059
1060fn check_machine_count(
1061    label: &str,
1062    value: u32,
1063    lowest: u32,
1064    highest: u32,
1065) -> std::result::Result<(), String> {
1066    if (lowest..=highest).contains(&value) {
1067        Ok(())
1068    } else {
1069        Err(format!(
1070            "{label} {value} is outside the allowed range {lowest}-{highest}"
1071        ))
1072    }
1073}
1074
1075fn needed_container_pool(container: &Container) -> &'static str {
1076    if container.stateful && container.persistent_storage.is_some() {
1077        return "stateful";
1078    }
1079    if container.gpu.is_some() {
1080        return "gpu";
1081    }
1082    if let Some(storage) = &container.ephemeral_storage {
1083        if instance_catalog::parse_memory_bytes(storage).unwrap_or(0) > 200 * 1024 * 1024 * 1024 {
1084            return "storage";
1085        }
1086    }
1087    "general"
1088}
1089
1090fn default_min_machines(requirements: &WorkloadRequirements) -> u32 {
1091    if requirements.total_cpu_at_desired > 0.0 || requirements.total_memory_bytes_at_desired > 0 {
1092        1
1093    } else {
1094        0
1095    }
1096}
1097
1098fn default_max_machines(requirements: &WorkloadRequirements) -> u32 {
1099    let min = default_min_machines(requirements);
1100    let by_cpu =
1101        (requirements.total_cpu_at_max / requirements.max_cpu_per_container.max(1.0)).ceil() as u32;
1102    let by_mem = requirements
1103        .total_memory_bytes_at_max
1104        .div_ceil(requirements.max_memory_per_container.max(1)) as u32;
1105    min.max(by_cpu).max(by_mem).max(1)
1106}
1107
1108#[cfg(test)]
1109mod tests {
1110    use super::*;
1111    use crate::{
1112        instance_catalog::Architecture, CapacityGroup, CapacityGroupScalePolicy,
1113        ComputeChoiceRange, ComputeCluster, ComputeSettings, ContainerCode, DaemonCode, Resource,
1114        ResourceEntry, ResourceLifecycle, Stack, Storage, Worker, WorkerCode,
1115    };
1116
1117    #[test]
1118    fn deployment_resources_drive_machine_planning_on_every_cloud() {
1119        let mut stack = stack_with_container();
1120        let container = stack
1121            .resources
1122            .get_mut("api")
1123            .unwrap()
1124            .config
1125            .downcast_mut::<Container>()
1126            .unwrap();
1127        container.resource_choices = Some(crate::ContainerResourceChoices {
1128            cpu: Some(crate::ResourceChoiceRange {
1129                min: "0.5".into(),
1130                max: "8".into(),
1131                default: "2".into(),
1132            }),
1133            memory: Some(crate::ResourceChoiceRange {
1134                min: "512Mi".into(),
1135                max: "16Gi".into(),
1136                default: "4Gi".into(),
1137            }),
1138        });
1139        let settings: ComputeSettings = serde_json::from_value(serde_json::json!({
1140            "pools": { "general": { "mode": "fixed", "machines": 1 } },
1141            "containers": { "api": { "cpu": 8, "memory": "16Gi" } }
1142        }))
1143        .unwrap();
1144        for platform in [Platform::Aws, Platform::Gcp, Platform::Azure] {
1145            let default_plan = plan_compute(&stack, platform, None).unwrap();
1146            let selected_plan = plan_compute(&stack, platform, Some(&settings)).unwrap();
1147            let pool = &selected_plan.pools[0];
1148            assert!(pool.errors.is_empty(), "{platform}: {:?}", pool.errors);
1149            assert_eq!(pool.requirements.cpu, "8");
1150            assert_eq!(pool.requirements.memory_bytes, 16 * 1024 * 1024 * 1024);
1151            assert_ne!(
1152                pool.selected.machine(),
1153                default_plan.pools[0].selected.machine()
1154            );
1155            assert_eq!(selected_plan.containers[0].cpu.min, "8");
1156            assert_eq!(selected_plan.containers[0].memory.desired, "16Gi");
1157            assert_eq!(pool.selected.min_size(), 1);
1158            assert_eq!(pool.selected.max_size(), 1);
1159        }
1160        assert_eq!(
1161            settings.pools["general"].machine(),
1162            None,
1163            "resolving a plan must preserve automatic machine intent"
1164        );
1165        let original = stack.resources["api"]
1166            .config
1167            .downcast_ref::<Container>()
1168            .unwrap();
1169        assert_eq!(
1170            original.cpu.desired, "2",
1171            "planning must not mutate release defaults"
1172        );
1173    }
1174
1175    #[test]
1176    fn explicit_machine_can_fit_a_low_cpu_high_memory_workload() {
1177        let mut stack = stack_with_container();
1178        let container = stack
1179            .resources
1180            .get_mut("api")
1181            .unwrap()
1182            .config
1183            .downcast_mut::<Container>()
1184            .unwrap();
1185        container.cpu = ResourceSpec {
1186            min: "0.5".into(),
1187            desired: "0.5".into(),
1188        };
1189        container.memory = ResourceSpec {
1190            min: "8Gi".into(),
1191            desired: "8Gi".into(),
1192        };
1193        let settings: ComputeSettings = serde_json::from_value(serde_json::json!({
1194            "pools": {"general":{"mode":"fixed","machines":1,"machine":"n2-standard-4"}}
1195        }))
1196        .unwrap();
1197        let plan = plan_compute(&stack, Platform::Gcp, Some(&settings)).unwrap();
1198        assert!(
1199            plan.pools[0].errors.is_empty(),
1200            "{:?}",
1201            plan.pools[0].errors
1202        );
1203        assert_eq!(plan.pools[0].selected.machine(), Some("n2-standard-4"));
1204    }
1205
1206    #[test]
1207    fn pin_must_leave_host_capacity_for_one_replica() {
1208        let mut stack = stack_with_container();
1209        stack
1210            .resources
1211            .get_mut("api")
1212            .unwrap()
1213            .config
1214            .downcast_mut::<Container>()
1215            .unwrap()
1216            .cpu = ResourceSpec {
1217            min: "4".into(),
1218            desired: "4".into(),
1219        };
1220        let settings: ComputeSettings = serde_json::from_value(serde_json::json!({
1221            "pools":{"general":{"mode":"fixed","machines":1,"machine":"t3.xlarge"}}
1222        }))
1223        .unwrap();
1224        let plan = plan_compute(&stack, Platform::Aws, Some(&settings)).unwrap();
1225        assert!(
1226            plan.pools[0]
1227                .errors
1228                .iter()
1229                .any(|error| error.contains("host resources")),
1230            "{:?}",
1231            plan.pools[0].errors
1232        );
1233    }
1234
1235    #[test]
1236    fn valid_pin_does_not_require_automatic_recommendation_headroom() {
1237        let machine = instance_catalog::catalog_for_platform(Platform::Gcp)
1238            .into_iter()
1239            .max_by_key(|machine| machine.memory_bytes)
1240            .unwrap();
1241        let mut stack = stack_with_container();
1242        let container = stack
1243            .resources
1244            .get_mut("api")
1245            .unwrap()
1246            .config
1247            .downcast_mut::<Container>()
1248            .unwrap();
1249        container.cpu = ResourceSpec {
1250            min: "0.5".into(),
1251            desired: "0.5".into(),
1252        };
1253        let memory = instance_catalog::allocatable_memory_bytes(machine).to_string();
1254        container.memory = ResourceSpec {
1255            min: memory.clone(),
1256            desired: memory,
1257        };
1258        assert!(plan_compute(&stack, Platform::Gcp, None).is_err());
1259        let settings: ComputeSettings = serde_json::from_value(serde_json::json!({
1260            "pools":{"general":{"mode":"fixed","machines":1,"machine":machine.name}}
1261        }))
1262        .unwrap();
1263        let plan = plan_compute(&stack, Platform::Gcp, Some(&settings)).unwrap();
1264        assert!(
1265            plan.pools[0].errors.is_empty(),
1266            "{:?}",
1267            plan.pools[0].errors
1268        );
1269        assert_eq!(plan.pools[0].selected.machine(), Some(machine.name));
1270    }
1271
1272    #[test]
1273    fn invalid_deployment_resources_fail_before_capacity_planning() {
1274        let mut stack = stack_with_container();
1275        stack
1276            .resources
1277            .get_mut("api")
1278            .unwrap()
1279            .config
1280            .downcast_mut::<Container>()
1281            .unwrap()
1282            .resource_choices = Some(crate::ContainerResourceChoices {
1283            cpu: Some(crate::ResourceChoiceRange {
1284                min: "0.5".into(),
1285                max: "4".into(),
1286                default: "2".into(),
1287            }),
1288            memory: Some(crate::ResourceChoiceRange {
1289                min: "512Mi".into(),
1290                max: "8Gi".into(),
1291                default: "4Gi".into(),
1292            }),
1293        });
1294        for selection in [
1295            serde_json::json!({"api":{"cpu": 0}}),
1296            serde_json::json!({"api":{"cpu": 5}}),
1297            serde_json::json!({"api":{"memory": "16Gi"}}),
1298            serde_json::json!({"api":{"memory": "NaNGi"}}),
1299            serde_json::json!({"api":{"memory": "-1Gi"}}),
1300            serde_json::json!({"missing":{"cpu": 1}}),
1301        ] {
1302            let settings: ComputeSettings =
1303                serde_json::from_value(serde_json::json!({"containers": selection})).unwrap();
1304            let error = plan_compute(&stack, Platform::Aws, Some(&settings)).unwrap_err();
1305            assert_eq!(error.code, "CONTAINER_RESOURCE_SELECTION_INVALID");
1306        }
1307    }
1308
1309    #[test]
1310    fn deployment_volume_selection_is_applied_without_changing_release_defaults() {
1311        let mut release = stack_with_container();
1312        release
1313            .resources
1314            .get_mut("api")
1315            .unwrap()
1316            .config
1317            .downcast_mut::<Container>()
1318            .unwrap()
1319            .persistent_storage = Some(crate::PersistentStorage {
1320            size: "20Gi".into(),
1321            mount_path: "/data".into(),
1322            backups: crate::VolumeBackups::default(),
1323            encryption_key: None,
1324        });
1325        let settings: ComputeSettings = serde_json::from_value(serde_json::json!({
1326            "containers": {"api": {"volumeSizeGib": 40}}
1327        }))
1328        .unwrap();
1329        let mut deployment = release.clone();
1330        crate::container_resources::resolve_container_resources(&mut deployment, Some(&settings))
1331            .unwrap();
1332        let volume = |stack: &Stack| {
1333            stack.resources["api"]
1334                .config
1335                .downcast_ref::<Container>()
1336                .unwrap()
1337                .persistent_storage
1338                .as_ref()
1339                .unwrap()
1340                .size
1341                .clone()
1342        };
1343        assert_eq!(volume(&deployment), "40Gi");
1344        assert_eq!(volume(&release), "20Gi");
1345        let mut equivalent = release.clone();
1346        equivalent
1347            .resources
1348            .get_mut("api")
1349            .unwrap()
1350            .config
1351            .downcast_mut::<Container>()
1352            .unwrap()
1353            .persistent_storage
1354            .as_mut()
1355            .unwrap()
1356            .size = "1Ti".into();
1357        let same_capacity: ComputeSettings = serde_json::from_value(serde_json::json!({
1358            "containers": {"api": {"volumeSizeGib": 1024}}
1359        }))
1360        .unwrap();
1361        crate::container_resources::resolve_container_resources(
1362            &mut equivalent,
1363            Some(&same_capacity),
1364        )
1365        .unwrap();
1366        assert_eq!(
1367            volume(&equivalent),
1368            "1Ti",
1369            "equivalent selections preserve PVC quantities"
1370        );
1371        crate::container_resources::resolve_container_resources(&mut deployment, Some(&settings))
1372            .unwrap();
1373        assert_eq!(volume(&deployment), "40Gi");
1374        let smaller_initial: ComputeSettings = serde_json::from_value(serde_json::json!({
1375            "containers": {"api": {"volumeSizeGib": 10}}
1376        }))
1377        .unwrap();
1378        crate::container_resources::resolve_container_resources(
1379            &mut release.clone(),
1380            Some(&smaller_initial),
1381        )
1382        .unwrap();
1383        let mut installed = StackState::new(Platform::Aws);
1384        installed.resources.insert(
1385            "api".into(),
1386            crate::StackResourceState::new_pending(
1387                "Container".into(),
1388                deployment.resources["api"].config.clone(),
1389                None,
1390                vec![],
1391            ),
1392        );
1393        let shrinking: ComputeSettings = serde_json::from_value(serde_json::json!({
1394            "containers": {"api": {"volumeSizeGib": 30}}
1395        }))
1396        .unwrap();
1397        let error =
1398            plan_compute_with_state(&release, Platform::Aws, Some(&shrinking), Some(&installed))
1399                .unwrap_err();
1400        assert_eq!(error.code, "CONTAINER_RESOURCE_SELECTION_INVALID");
1401        assert!(error.to_string().contains("cannot shrink from 40 to 30"));
1402        let imported: ComputeSettings = serde_json::from_value(serde_json::json!({
1403            "containers": {"api": {"volumeSizeGib": "40"}}
1404        }))
1405        .unwrap();
1406        assert_eq!(imported.containers["api"].volume_size_gib, Some(40));
1407        for value in [
1408            serde_json::json!(-1),
1409            serde_json::json!(1.5),
1410            serde_json::json!("4294967296"),
1411        ] {
1412            assert!(
1413                serde_json::from_value::<ComputeSettings>(serde_json::json!({
1414                    "containers": {"api": {"volumeSizeGib": value}}
1415                }))
1416                .is_err()
1417            );
1418        }
1419        for (platform, size) in [(Platform::Gcp, 9), (Platform::Azure, 32768)] {
1420            let invalid: ComputeSettings = serde_json::from_value(serde_json::json!({
1421                "containers": {"api": {"volumeSizeGib": size}}
1422            }))
1423            .unwrap();
1424            let error = plan_compute(&release, platform, Some(&invalid)).unwrap_err();
1425            assert_eq!(error.code, "CONTAINER_RESOURCE_SELECTION_INVALID");
1426            assert!(error.to_string().contains("must be between"));
1427        }
1428        for size in [0, 65537] {
1429            let invalid: ComputeSettings = serde_json::from_value(serde_json::json!({
1430                "containers": {"api": {"volumeSizeGib": size}}
1431            }))
1432            .unwrap();
1433            assert_eq!(
1434                crate::container_resources::resolve_container_resources(
1435                    &mut release.clone(),
1436                    Some(&invalid)
1437                )
1438                .unwrap_err()
1439                .code,
1440                "CONTAINER_RESOURCE_SELECTION_INVALID"
1441            );
1442        }
1443        assert_eq!(
1444            crate::container_resources::resolve_container_resources(
1445                &mut stack_with_container(),
1446                Some(&settings)
1447            )
1448            .unwrap_err()
1449            .code,
1450            "CONTAINER_RESOURCE_SELECTION_INVALID"
1451        );
1452    }
1453
1454    fn stack_with_volume(size: &str) -> Stack {
1455        let mut stack = stack_with_container();
1456        stack
1457            .resources
1458            .get_mut("api")
1459            .unwrap()
1460            .config
1461            .downcast_mut::<Container>()
1462            .unwrap()
1463            .persistent_storage = Some(crate::PersistentStorage {
1464            size: size.into(),
1465            mount_path: "/data".into(),
1466            backups: crate::VolumeBackups::default(),
1467            encryption_key: None,
1468        });
1469        stack
1470    }
1471
1472    fn installed_with_volume(platform: Platform, size: &str) -> StackState {
1473        let mut installed = StackState::new(platform);
1474        installed.resources.insert(
1475            "api".into(),
1476            crate::StackResourceState::new_pending(
1477                "Container".into(),
1478                stack_with_volume(size).resources["api"].config.clone(),
1479                None,
1480                vec![],
1481            ),
1482        );
1483        installed
1484    }
1485
1486    fn api_volume(plan: &ComputePlan) -> (Option<u32>, Option<u32>) {
1487        let api = plan
1488            .containers
1489            .iter()
1490            .find(|container| container.container_id == "api")
1491            .expect("api is planned");
1492        (api.volume_size_gib, api.minimum_volume_size_gib)
1493    }
1494
1495    /// A release that lowers its default volume size must not turn into a shrink of every
1496    /// installed deployment that never chose a size: they keep their installed volumes. A
1497    /// larger default still grows them.
1498    #[test]
1499    fn a_lower_release_default_keeps_installed_volumes_without_a_selection() {
1500        for platform in [Platform::Aws, Platform::Gcp, Platform::Azure] {
1501            let installed = installed_with_volume(platform, "30Gi");
1502            let plan = plan_compute_with_state(
1503                &stack_with_volume("20Gi"),
1504                platform,
1505                None,
1506                Some(&installed),
1507            )
1508            .expect("a lower release default keeps the installed volume");
1509            assert_eq!(api_volume(&plan), (Some(30), Some(30)), "{platform}");
1510
1511            let plan = plan_compute_with_state(
1512                &stack_with_volume("40Gi"),
1513                platform,
1514                None,
1515                Some(&installed),
1516            )
1517            .expect("a larger release default grows the volume");
1518            assert_eq!(api_volume(&plan), (Some(40), Some(30)), "{platform}");
1519
1520            // A selection is the deployment's own choice, so it still cannot shrink.
1521            let shrinking: ComputeSettings = serde_json::from_value(serde_json::json!({
1522                "containers": {"api": {"volumeSizeGib": 25}}
1523            }))
1524            .unwrap();
1525            let error = plan_compute_with_state(
1526                &stack_with_volume("20Gi"),
1527                platform,
1528                Some(&shrinking),
1529                Some(&installed),
1530            )
1531            .unwrap_err();
1532            assert!(
1533                error.to_string().contains("cannot shrink from 30 to 25"),
1534                "{error}"
1535            );
1536        }
1537    }
1538
1539    /// Setup sources (CloudFormation parameters, Terraform variables, CLI config files) cannot see
1540    /// growth made through a compute update. Their lower values keep the installed size; equal
1541    /// or larger values, and containers without an installed volume, are left alone.
1542    #[test]
1543    fn setup_source_volume_sizes_below_installed_keep_the_installed_size() {
1544        let mut installed = installed_with_volume(Platform::Aws, "30Gi");
1545        let mut worker = stack_with_volume("10Gi").resources["api"].config.clone();
1546        worker
1547            .downcast_mut::<Container>()
1548            .unwrap()
1549            .persistent_storage = None;
1550        installed.resources.insert(
1551            "worker".into(),
1552            crate::StackResourceState::new_pending("Container".into(), worker, None, vec![]),
1553        );
1554        let mut settings: ComputeSettings = serde_json::from_value(serde_json::json!({
1555            "containers": {
1556                "api": {"volumeSizeGib": 25, "cpu": 2},
1557                "worker": {"volumeSizeGib": 5},
1558                "new": {"volumeSizeGib": 8}
1559            }
1560        }))
1561        .unwrap();
1562
1563        let kept = crate::container_resources::keep_volume_sizes_over_setup_source(
1564            &mut settings,
1565            Some(&installed),
1566            None,
1567        );
1568
1569        assert_eq!(
1570            kept,
1571            vec![crate::container_resources::KeptVolumeSize {
1572                container_id: "api".into(),
1573                requested_gib: 25,
1574                kept_gib: 30,
1575            }]
1576        );
1577        assert_eq!(settings.containers["api"].volume_size_gib, Some(30));
1578        assert_eq!(
1579            settings.containers["api"].cpu,
1580            Some(serde_json::Number::from(2))
1581        );
1582        assert_eq!(settings.containers["worker"].volume_size_gib, Some(5));
1583        assert_eq!(settings.containers["new"].volume_size_gib, Some(8));
1584        let plan = plan_compute_with_state(
1585            &stack_with_volume("20Gi"),
1586            Platform::Aws,
1587            Some(&ComputeSettings {
1588                containers: [("api".to_string(), settings.containers["api"].clone())].into(),
1589                ..Default::default()
1590            }),
1591            Some(&installed_with_volume(Platform::Aws, "30Gi")),
1592        )
1593        .expect("the kept size plans");
1594        assert_eq!(api_volume(&plan), (Some(30), Some(30)));
1595
1596        let mut growing: ComputeSettings = serde_json::from_value(serde_json::json!({
1597            "containers": {"api": {"volumeSizeGib": 35}}
1598        }))
1599        .unwrap();
1600        assert!(
1601            crate::container_resources::keep_volume_sizes_over_setup_source(
1602                &mut growing,
1603                Some(&installed),
1604                None,
1605            )
1606            .is_empty()
1607        );
1608        assert_eq!(growing.containers["api"].volume_size_gib, Some(35));
1609
1610        // A growth the deployment chose but has not applied yet is not undone either.
1611        let chosen: ComputeSettings = serde_json::from_value(serde_json::json!({
1612            "containers": {"api": {"volumeSizeGib": 40}}
1613        }))
1614        .unwrap();
1615        let mut stale: ComputeSettings = serde_json::from_value(serde_json::json!({
1616            "containers": {"api": {"volumeSizeGib": 25}}
1617        }))
1618        .unwrap();
1619        let kept = crate::container_resources::keep_volume_sizes_over_setup_source(
1620            &mut stale,
1621            Some(&installed),
1622            Some(&chosen),
1623        );
1624        assert_eq!(kept[0].kept_gib, 40);
1625        assert_eq!(stale.containers["api"].volume_size_gib, Some(40));
1626
1627        // Before anything is installed (a first install whose preflights failed), a smaller
1628        // value is a correction of the saved choice and is kept as asked.
1629        let mut correction: ComputeSettings = serde_json::from_value(serde_json::json!({
1630            "containers": {"api": {"volumeSizeGib": 20}}
1631        }))
1632        .unwrap();
1633        for nothing_installed in [None, Some(&StackState::new(Platform::Aws))] {
1634            assert!(
1635                crate::container_resources::keep_volume_sizes_over_setup_source(
1636                    &mut correction,
1637                    nothing_installed,
1638                    Some(&chosen),
1639                )
1640                .is_empty()
1641            );
1642            assert_eq!(correction.containers["api"].volume_size_gib, Some(20));
1643        }
1644    }
1645
1646    #[test]
1647    fn installed_storage_cannot_be_removed_or_remounted_on_any_provider() {
1648        for platform in [
1649            Platform::Aws,
1650            Platform::Gcp,
1651            Platform::Azure,
1652            Platform::Kubernetes,
1653        ] {
1654            let mut release = stack_with_container();
1655            release
1656                .resources
1657                .get_mut("api")
1658                .unwrap()
1659                .config
1660                .downcast_mut::<Container>()
1661                .unwrap()
1662                .persistent_storage = Some(crate::PersistentStorage {
1663                size: "20Gi".into(),
1664                mount_path: "/data".into(),
1665                backups: crate::VolumeBackups::default(),
1666                encryption_key: None,
1667            });
1668            let mut installed = StackState::new(platform);
1669            installed.resources.insert(
1670                "api".into(),
1671                crate::StackResourceState::new_pending(
1672                    "Container".into(),
1673                    release.resources["api"].config.clone(),
1674                    None,
1675                    vec![],
1676                ),
1677            );
1678            for remove in [false, true] {
1679                let mut desired = release.clone();
1680                let storage = &mut desired
1681                    .resources
1682                    .get_mut("api")
1683                    .unwrap()
1684                    .config
1685                    .downcast_mut::<Container>()
1686                    .unwrap()
1687                    .persistent_storage;
1688                if remove {
1689                    *storage = None;
1690                } else {
1691                    storage.as_mut().unwrap().mount_path = "/other".into();
1692                }
1693                let error = plan_compute_with_state(&desired, platform, None, Some(&installed))
1694                    .unwrap_err();
1695                assert_eq!(error.code, "CONTAINER_RESOURCE_SELECTION_INVALID");
1696                assert!(
1697                    error.to_string().contains(if remove {
1698                        "removing installed"
1699                    } else {
1700                        "mount path"
1701                    }),
1702                    "{error}"
1703                );
1704            }
1705        }
1706    }
1707
1708    #[test]
1709    fn release_volume_defaults_obey_provider_bounds_without_a_selection() {
1710        for (platform, size) in [(Platform::Gcp, "5Gi"), (Platform::Azure, "40000Gi")] {
1711            let mut stack = stack_with_container();
1712            stack
1713                .resources
1714                .get_mut("api")
1715                .unwrap()
1716                .config
1717                .downcast_mut::<Container>()
1718                .unwrap()
1719                .persistent_storage = Some(crate::PersistentStorage {
1720                size: size.into(),
1721                mount_path: "/data".into(),
1722                backups: crate::VolumeBackups::default(),
1723                encryption_key: None,
1724            });
1725            let error = plan_compute(&stack, platform, None).unwrap_err();
1726            assert_eq!(error.code, "CONTAINER_RESOURCE_SELECTION_INVALID");
1727            assert!(error.to_string().contains("must be between"));
1728        }
1729    }
1730
1731    #[test]
1732    fn volume_capacity_parsing_rejects_overflow_and_preserves_legacy_units() {
1733        use crate::container_resources::parse_volume_size_gib;
1734        for (value, size) in [
1735            ("20Gi", 20),
1736            ("20GiB", 20),
1737            ("20GB", 20),
1738            ("1Ti", 1024),
1739            ("1TB", 1000),
1740            ("1.5Ti", 1536),
1741            ("1536Mi", 2),
1742            ("0.5Gi", 1),
1743            ("1073741824", 1),
1744        ] {
1745            assert_eq!(parse_volume_size_gib(value).unwrap(), size);
1746        }
1747        for value in ["0Gi", "-1Gi", "NaNGi", "4294967295Ti", "65537Gi"] {
1748            assert!(parse_volume_size_gib(value).is_err(), "{value}");
1749        }
1750    }
1751
1752    fn stack_with_container() -> Stack {
1753        let container = Container::new("api".to_string())
1754            .code(ContainerCode::Image {
1755                image: "api:latest".to_string(),
1756            })
1757            .cpu(ResourceSpec {
1758                min: "1".to_string(),
1759                desired: "2".to_string(),
1760            })
1761            .memory(ResourceSpec {
1762                min: "2Gi".to_string(),
1763                desired: "4Gi".to_string(),
1764            })
1765            .permissions("api".to_string())
1766            .build();
1767        Stack {
1768            id: "test".to_string(),
1769            resources: [(
1770                "api".to_string(),
1771                ResourceEntry {
1772                    config: Resource::new(container),
1773                    lifecycle: ResourceLifecycle::Live,
1774                    dependencies: Vec::new(),
1775                    remote_access: false,
1776                    enabled_when: None,
1777                },
1778            )]
1779            .into_iter()
1780            .collect(),
1781            permissions: crate::permissions::PermissionsConfig::default(),
1782            supported_platforms: None,
1783            inputs: vec![],
1784            dynamic_container_repositories: Vec::new(),
1785            dynamic_container_image_resources: Vec::new(),
1786            operations: None,
1787        }
1788    }
1789
1790    #[test]
1791    fn serverless_workers_and_storage_do_not_allocate_machine_pools() {
1792        let mut stack = stack_with_container();
1793        stack.resources.clear();
1794        for (id, resource) in [
1795            (
1796                "api",
1797                Resource::new(
1798                    Worker::new("api".to_string())
1799                        .code(WorkerCode::Image {
1800                            image: "example/worker:v1".to_string(),
1801                        })
1802                        .permissions("execution".to_string())
1803                        .build(),
1804                ),
1805            ),
1806            (
1807                "data",
1808                Resource::new(Storage::new("data".to_string()).build()),
1809            ),
1810        ] {
1811            stack.resources.insert(
1812                id.to_string(),
1813                ResourceEntry {
1814                    config: resource,
1815                    lifecycle: ResourceLifecycle::Frozen,
1816                    dependencies: Vec::new(),
1817                    remote_access: false,
1818                    enabled_when: None,
1819                },
1820            );
1821        }
1822
1823        for platform in [Platform::Aws, Platform::Gcp, Platform::Azure] {
1824            let plan = plan_compute(&stack, platform, None).expect("plan should build");
1825            assert!(plan.pools.is_empty(), "serverless plan allocated {plan:?}");
1826        }
1827    }
1828
1829    #[test]
1830    fn empty_stack_does_not_allocate_machine_pools() {
1831        let mut stack = stack_with_container();
1832        stack.resources.clear();
1833        let plan = plan_compute(&stack, Platform::Aws, None).expect("plan should build");
1834        assert!(plan.pools.is_empty());
1835    }
1836
1837    #[test]
1838    fn explicit_cluster_without_workloads_only_plans_declared_pools() {
1839        let mut stack = stack_with_container();
1840        stack.resources.clear();
1841        let cluster = ComputeCluster::new("compute".to_string())
1842            .capacity_group(CapacityGroup {
1843                group_id: "batch".to_string(),
1844                instance_type: Some("t4g.medium".to_string()),
1845                profile: None,
1846                min_size: 1,
1847                max_size: 1,
1848                scale_policy: None,
1849                nested_virtualization: None,
1850            })
1851            .build();
1852        stack.resources.insert(
1853            "compute".to_string(),
1854            ResourceEntry {
1855                config: Resource::new(cluster),
1856                lifecycle: ResourceLifecycle::Frozen,
1857                dependencies: Vec::new(),
1858                remote_access: false,
1859                enabled_when: None,
1860            },
1861        );
1862
1863        let plan = plan_compute(&stack, Platform::Aws, None).expect("plan should build");
1864        assert_eq!(plan.pools.len(), 1);
1865        assert_eq!(plan.pools[0].pool_id, "batch");
1866        assert!(plan.pools[0].errors.is_empty());
1867    }
1868
1869    #[test]
1870    fn cloud_plan_recommends_provider_machine_without_mutating_selection() {
1871        let stack = stack_with_container();
1872
1873        let plan = plan_compute(&stack, Platform::Aws, None).expect("plan should build");
1874
1875        let pool = plan.pools.first().expect("general pool should exist");
1876        assert_eq!(pool.pool_id, "general");
1877        assert_eq!(pool.workloads, vec!["api"]);
1878        assert!(pool.selected.machine().is_some());
1879        assert!(pool.machines.iter().any(|machine| machine.recommended));
1880    }
1881
1882    #[test]
1883    fn cloud_plan_only_offers_machines_matching_the_default_image_target() {
1884        let stack = stack_with_container();
1885
1886        for platform in [Platform::Aws, Platform::Gcp, Platform::Azure] {
1887            let plan = plan_compute(&stack, platform, None).expect("plan should build");
1888            let pool = plan.pools.first().expect("general pool should exist");
1889            let expected = instance_catalog::default_architecture(platform)
1890                .expect("managed cloud should have a default architecture");
1891
1892            assert!(pool.machines.iter().all(|machine| {
1893                instance_catalog::find_instance_type(platform, &machine.machine)
1894                    .is_some_and(|spec| spec.architecture == expected)
1895            }));
1896        }
1897    }
1898
1899    #[test]
1900    fn selected_machine_is_preserved_as_static_deployment_choice() {
1901        let stack = stack_with_container();
1902        let settings = ComputeSettings {
1903            containers: Default::default(),
1904            pools: [(
1905                "general".to_string(),
1906                ComputePoolSelection::Fixed {
1907                    machines: 1,
1908                    machine: Some("m7g.xlarge".to_string()),
1909                    failure_domains: None,
1910                },
1911            )]
1912            .into_iter()
1913            .collect(),
1914        };
1915
1916        let plan = plan_compute(&stack, Platform::Aws, Some(&settings)).expect("plan should build");
1917
1918        let pool = plan.pools.first().expect("general pool should exist");
1919        assert_eq!(pool.selected.machine(), Some("m7g.xlarge"));
1920        assert!(pool.errors.is_empty());
1921    }
1922
1923    #[test]
1924    fn configurable_cloud_disk_satisfies_storage_above_fixed_local_catalog() {
1925        let mut stack = stack_with_container();
1926        let container = stack
1927            .resources
1928            .get_mut("api")
1929            .and_then(|entry| entry.config.downcast_mut::<Container>())
1930            .expect("test stack should contain a container");
1931        container.ephemeral_storage = Some("8000Gi".to_string());
1932        let settings = ComputeSettings {
1933            containers: Default::default(),
1934            pools: [(
1935                "storage".to_string(),
1936                ComputePoolSelection::Fixed {
1937                    machines: 1,
1938                    machine: Some("m8i.2xlarge".to_string()),
1939                    failure_domains: None,
1940                },
1941            )]
1942            .into_iter()
1943            .collect(),
1944        };
1945
1946        let plan = plan_compute(&stack, Platform::Aws, Some(&settings)).expect("plan should build");
1947        let pool = plan.pools.first().expect("general pool should exist");
1948        assert!(pool.errors.is_empty());
1949        let selected = pool
1950            .machines
1951            .iter()
1952            .find(|option| option.machine == "m8i.2xlarge")
1953            .expect("selected machine should remain an option");
1954        assert_eq!(
1955            selected.profile.ephemeral_storage_bytes,
1956            8_000 * 1024 * 1024 * 1024
1957        );
1958    }
1959
1960    #[test]
1961    fn selected_machine_defines_architecture_when_workloads_do_not() {
1962        let stack = stack_with_container();
1963        let settings = ComputeSettings {
1964            containers: Default::default(),
1965            pools: [(
1966                "general".to_string(),
1967                ComputePoolSelection::Fixed {
1968                    machines: 1,
1969                    machine: Some("m7i.xlarge".to_string()),
1970                    failure_domains: None,
1971                },
1972            )]
1973            .into_iter()
1974            .collect(),
1975        };
1976
1977        let plan = plan_compute(&stack, Platform::Aws, Some(&settings)).expect("plan should build");
1978
1979        let pool = plan.pools.first().expect("general pool should exist");
1980        assert!(pool.errors.is_empty());
1981        assert!(pool.machines.iter().all(|machine| {
1982            instance_catalog::find_instance_type(Platform::Aws, &machine.machine)
1983                .is_some_and(|spec| spec.architecture == Architecture::X86_64)
1984        }));
1985    }
1986
1987    #[test]
1988    fn graviton4_compute_machine_can_be_selected() {
1989        let stack = stack_with_container();
1990        let settings = ComputeSettings {
1991            containers: Default::default(),
1992            pools: [(
1993                "general".to_string(),
1994                ComputePoolSelection::Fixed {
1995                    machines: 1,
1996                    machine: Some("c8g.2xlarge".to_string()),
1997                    failure_domains: None,
1998                },
1999            )]
2000            .into_iter()
2001            .collect(),
2002        };
2003
2004        let plan = plan_compute(&stack, Platform::Aws, Some(&settings)).expect("plan should build");
2005
2006        let pool = plan.pools.first().expect("general pool should exist");
2007        assert!(pool.errors.is_empty(), "{:?}", pool.errors);
2008        let selected = pool
2009            .machines
2010            .iter()
2011            .find(|option| option.machine == "c8g.2xlarge")
2012            .expect("selected machine should be offered");
2013        assert!(selected.recommended);
2014        assert_eq!(selected.profile.architecture, Some(Architecture::Arm64));
2015    }
2016
2017    #[test]
2018    fn explicit_capacity_group_requirements_are_merged_with_workloads() {
2019        let mut stack = stack_with_container();
2020        let cluster = ComputeCluster::new("compute".to_string())
2021            .capacity_group(CapacityGroup {
2022                group_id: "general".to_string(),
2023                instance_type: None,
2024                profile: Some(MachineProfile {
2025                    cpu: "4".to_string(),
2026                    memory_bytes: 16 * 1024 * 1024 * 1024,
2027                    ephemeral_storage_bytes: 20 * 1024 * 1024 * 1024,
2028                    architecture: Some(Architecture::X86_64),
2029                    gpu: None,
2030                }),
2031                min_size: 2,
2032                max_size: 5,
2033                scale_policy: None,
2034                nested_virtualization: Some(true),
2035            })
2036            .build();
2037        stack.resources.insert(
2038            "compute".to_string(),
2039            ResourceEntry {
2040                config: Resource::new(cluster),
2041                lifecycle: ResourceLifecycle::Frozen,
2042                dependencies: Vec::new(),
2043                remote_access: false,
2044                enabled_when: None,
2045            },
2046        );
2047
2048        let plan = plan_compute(&stack, Platform::Aws, None).expect("plan should build");
2049
2050        let pool = plan.pools.first().expect("general pool should exist");
2051        let machine = pool
2052            .selected
2053            .machine()
2054            .expect("AWS selection should include a machine");
2055        let spec = instance_catalog::find_instance_type(Platform::Aws, machine)
2056            .expect("selected machine should exist in the catalog");
2057        assert!(spec.is_nested_virt_capable());
2058        assert_eq!(pool.selected.min_size(), 2);
2059        assert_eq!(pool.selected.max_size(), 5);
2060        assert!(pool.errors.is_empty());
2061    }
2062
2063    #[test]
2064    fn materialized_machine_profile_is_not_counted_as_workload_demand() {
2065        let mut stack = stack_with_container();
2066        let selected = instance_catalog::find_instance_type(Platform::Aws, "m7g.xlarge")
2067            .expect("machine should exist in catalog");
2068        let cluster = ComputeCluster::new("compute".to_string())
2069            .capacity_group(CapacityGroup {
2070                group_id: "general".to_string(),
2071                instance_type: Some(selected.name.to_string()),
2072                profile: Some(selected.to_machine_profile()),
2073                min_size: 1,
2074                max_size: 1,
2075                scale_policy: None,
2076                nested_virtualization: None,
2077            })
2078            .build();
2079        stack.resources.insert(
2080            "compute".to_string(),
2081            ResourceEntry {
2082                config: Resource::new(cluster),
2083                lifecycle: ResourceLifecycle::Frozen,
2084                dependencies: Vec::new(),
2085                remote_access: false,
2086                enabled_when: None,
2087            },
2088        );
2089
2090        let plan = plan_compute(&stack, Platform::Aws, None).expect("plan should build");
2091        let pool = plan.pools.first().expect("general pool should exist");
2092
2093        assert_eq!(pool.requirements.cpu, "2");
2094        assert_eq!(pool.requirements.memory_bytes, 4 * 1024 * 1024 * 1024);
2095        assert_eq!(pool.recommended.machine(), Some("m7g.xlarge"));
2096        assert!(pool
2097            .machines
2098            .iter()
2099            .any(|option| option.machine == "m7g.xlarge"));
2100    }
2101
2102    #[test]
2103    fn nested_x86_fixed_range_pool_preserves_bounds_and_rejects_graviton() {
2104        let daemon = Daemon::new("vm-runtime-loader".to_string())
2105            .code(DaemonCode::Image {
2106                image: "example.com/vm-runtime:latest".to_string(),
2107            })
2108            .cluster("vm-runtime".to_string())
2109            .cpu(ResourceSpec {
2110                min: "2".to_string(),
2111                desired: "2".to_string(),
2112            })
2113            .memory(ResourceSpec {
2114                min: "4Gi".to_string(),
2115                desired: "4Gi".to_string(),
2116            })
2117            .permissions("loader".to_string())
2118            .build();
2119        let cluster = ComputeCluster::new("vm-runtime".to_string())
2120            .capacity_group(CapacityGroup {
2121                group_id: "general".to_string(),
2122                instance_type: None,
2123                profile: Some(MachineProfile {
2124                    cpu: "4".to_string(),
2125                    memory_bytes: 16 * 1024 * 1024 * 1024,
2126                    ephemeral_storage_bytes: 20 * 1024 * 1024 * 1024,
2127                    architecture: Some(Architecture::X86_64),
2128                    gpu: None,
2129                }),
2130                min_size: 2,
2131                max_size: 2,
2132                scale_policy: Some(CapacityGroupScalePolicy::Fixed {
2133                    machines: ComputeChoiceRange {
2134                        min: 1,
2135                        max: 5,
2136                        default: 2,
2137                    },
2138                }),
2139                nested_virtualization: Some(true),
2140            })
2141            .build();
2142        let stack = Stack {
2143            id: "vm-runtime".to_string(),
2144            resources: [
2145                (
2146                    "vm-runtime-loader".to_string(),
2147                    ResourceEntry {
2148                        config: Resource::new(daemon),
2149                        lifecycle: ResourceLifecycle::Live,
2150                        dependencies: Vec::new(),
2151                        remote_access: false,
2152                        enabled_when: None,
2153                    },
2154                ),
2155                (
2156                    "vm-runtime".to_string(),
2157                    ResourceEntry {
2158                        config: Resource::new(cluster),
2159                        lifecycle: ResourceLifecycle::Frozen,
2160                        dependencies: Vec::new(),
2161                        remote_access: false,
2162                        enabled_when: None,
2163                    },
2164                ),
2165            ]
2166            .into_iter()
2167            .collect(),
2168            permissions: crate::permissions::PermissionsConfig::default(),
2169            supported_platforms: None,
2170            inputs: vec![],
2171            dynamic_container_repositories: Vec::new(),
2172            dynamic_container_image_resources: Vec::new(),
2173            operations: None,
2174        };
2175
2176        let plan = plan_compute(&stack, Platform::Aws, None).expect("plan should build");
2177        let pool = plan.pools.first().expect("general pool should exist");
2178        assert_eq!(pool.recommended.machine(), Some("m8i.2xlarge"));
2179        assert_eq!(pool.recommended.min_size(), 2);
2180        assert_eq!(pool.recommended.max_size(), 2);
2181        assert_eq!(
2182            pool.scale,
2183            CapacityGroupScalePolicy::Fixed {
2184                machines: ComputeChoiceRange {
2185                    min: 1,
2186                    max: 5,
2187                    default: 2,
2188                },
2189            }
2190        );
2191        assert!(!pool
2192            .machines
2193            .iter()
2194            .any(|option| option.machine == "m7g.2xlarge"));
2195
2196        let invalid_settings = ComputeSettings {
2197            containers: Default::default(),
2198            pools: [(
2199                "general".to_string(),
2200                ComputePoolSelection::Fixed {
2201                    machines: 2,
2202                    machine: Some("m7g.2xlarge".to_string()),
2203                    failure_domains: None,
2204                },
2205            )]
2206            .into_iter()
2207            .collect(),
2208        };
2209        let invalid_plan = plan_compute(&stack, Platform::Aws, Some(&invalid_settings))
2210            .expect("plan should build");
2211        assert!(!invalid_plan.pools[0].errors.is_empty());
2212    }
2213
2214    fn declared_pool_errors(
2215        scale: CapacityGroupScalePolicy,
2216        selection: ComputePoolSelection,
2217    ) -> Vec<String> {
2218        let mut stack = stack_with_container();
2219        let cluster = ComputeCluster::new("compute".to_string())
2220            .capacity_group(CapacityGroup {
2221                group_id: "general".to_string(),
2222                instance_type: None,
2223                profile: None,
2224                min_size: scale.default_min_size(),
2225                max_size: scale.default_max_size(),
2226                scale_policy: Some(scale),
2227                nested_virtualization: None,
2228            })
2229            .build();
2230        stack.resources.insert(
2231            "compute".to_string(),
2232            ResourceEntry {
2233                config: Resource::new(cluster),
2234                lifecycle: ResourceLifecycle::Frozen,
2235                dependencies: Vec::new(),
2236                remote_access: false,
2237                enabled_when: None,
2238            },
2239        );
2240        let settings = ComputeSettings {
2241            containers: Default::default(),
2242            pools: [("general".to_string(), selection)].into_iter().collect(),
2243        };
2244        plan_compute(&stack, Platform::Aws, Some(&settings))
2245            .expect("plan should build")
2246            .pools
2247            .remove(0)
2248            .errors
2249    }
2250
2251    fn range(min: u32, max: u32, default: u32) -> ComputeChoiceRange {
2252        ComputeChoiceRange { min, max, default }
2253    }
2254
2255    #[test]
2256    fn generated_pool_offers_the_recommendation_as_a_default_not_a_bound() {
2257        let mut stack = stack_with_container();
2258        let api = stack
2259            .resources
2260            .get_mut("api")
2261            .and_then(|entry| entry.config.downcast_mut::<Container>())
2262            .expect("test stack should contain a container");
2263        api.autoscaling = Some(crate::ContainerAutoscaling {
2264            min: 1,
2265            desired: 1,
2266            max: 3,
2267            target_cpu_percent: Some(70.0),
2268            target_memory_percent: None,
2269            target_http_in_flight_per_replica: None,
2270            max_http_p95_latency_ms: None,
2271        });
2272
2273        let plan = plan_compute(&stack, Platform::Aws, None).expect("plan should build");
2274        let pool = &plan.pools[0];
2275        assert_eq!(
2276            pool.scale,
2277            CapacityGroupScalePolicy::Autoscale {
2278                min: range(1, 100, 1),
2279                max: range(1, 100, 3),
2280            }
2281        );
2282        assert_eq!(
2283            (pool.recommended.min_size(), pool.recommended.max_size()),
2284            (1, 3)
2285        );
2286
2287        // Workloads that never scale get a fixed recommendation, but either mode stays
2288        // allowed: replica counts change between releases while the saved choice stays.
2289        let plan =
2290            plan_compute(&stack_with_container(), Platform::Aws, None).expect("plan should build");
2291        assert_eq!(
2292            plan.pools[0].scale,
2293            CapacityGroupScalePolicy::Autoscale {
2294                min: range(1, 100, 1),
2295                max: range(1, 100, 1),
2296            }
2297        );
2298        assert!(matches!(
2299            plan.pools[0].recommended,
2300            ComputePoolSelection::Fixed { machines: 1, .. }
2301        ));
2302        assert!(plan.pools[0].errors.is_empty());
2303    }
2304
2305    #[test]
2306    fn generated_pool_rejects_a_fleet_too_small_for_desired_replicas() {
2307        let mut stack = stack_with_container();
2308        let api = stack
2309            .resources
2310            .get_mut("api")
2311            .and_then(|entry| entry.config.downcast_mut::<Container>())
2312            .expect("test stack should contain a container");
2313        // 3 x 2 vCPU / 4 GiB requested at the desired count.
2314        api.replicas = Some(3);
2315        let selection = |machines| ComputeSettings {
2316            containers: Default::default(),
2317            pools: [(
2318                "general".to_string(),
2319                ComputePoolSelection::Fixed {
2320                    machines,
2321                    machine: Some("m7g.xlarge".to_string()),
2322                    failure_domains: None,
2323                },
2324            )]
2325            .into_iter()
2326            .collect(),
2327        };
2328
2329        // m7g.xlarge: 3.5 vCPU available after the host reserve.
2330        let errors = plan_compute(&stack, Platform::Aws, Some(&selection(1)))
2331            .expect("plan should build")
2332            .pools
2333            .remove(0)
2334            .errors;
2335        assert_eq!(errors.len(), 1, "{errors:?}");
2336        assert!(
2337            errors[0].starts_with(
2338                "Pool 'general' is too small for its workloads: 1 x m7g.xlarge has 3.50 vCPU"
2339            ),
2340            "{errors:?}"
2341        );
2342        assert!(errors[0].contains("request 6.00 vCPU"), "{errors:?}");
2343
2344        let errors = plan_compute(&stack, Platform::Aws, Some(&selection(2)))
2345            .expect("plan should build")
2346            .pools
2347            .remove(0)
2348            .errors;
2349        assert!(errors.is_empty(), "{errors:?}");
2350    }
2351
2352    #[test]
2353    fn fixed_and_equal_autoscale_bounds_are_interchangeable_for_declared_pools() {
2354        let fixed = |machines| ComputePoolSelection::Fixed {
2355            machines,
2356            machine: Some("m7g.xlarge".to_string()),
2357            failure_domains: None,
2358        };
2359        let autoscale = |min, max| ComputePoolSelection::Autoscale {
2360            min,
2361            max,
2362            machine: Some("m7g.xlarge".to_string()),
2363            failure_domains: None,
2364        };
2365        let declared_fixed = CapacityGroupScalePolicy::Fixed {
2366            machines: range(2, 4, 2),
2367        };
2368        let declared_autoscale = CapacityGroupScalePolicy::Autoscale {
2369            min: range(1, 3, 1),
2370            max: range(2, 6, 4),
2371        };
2372
2373        assert!(declared_pool_errors(declared_fixed.clone(), autoscale(3, 3)).is_empty());
2374        assert_eq!(
2375            declared_pool_errors(declared_fixed.clone(), autoscale(2, 4)),
2376            vec!["Pool 'general' must use fixed mode with 2-4 machines; autoscale 2-4 is not allowed"]
2377        );
2378        assert_eq!(
2379            declared_pool_errors(declared_fixed, autoscale(5, 5)),
2380            vec!["Pool 'general' machine count 5 is outside the allowed range 2-4"]
2381        );
2382
2383        // Fixed N must fit both the declared minimum and maximum ranges.
2384        assert!(declared_pool_errors(declared_autoscale.clone(), fixed(3)).is_empty());
2385        assert_eq!(
2386            declared_pool_errors(declared_autoscale.clone(), fixed(1)),
2387            vec!["Pool 'general' fixed machine count 1 is outside the allowed range 2-3"]
2388        );
2389        assert_eq!(
2390            declared_pool_errors(
2391                CapacityGroupScalePolicy::Autoscale {
2392                    min: range(1, 1, 1),
2393                    max: range(3, 5, 3),
2394                },
2395                fixed(1),
2396            ),
2397            vec!["Pool 'general' must use autoscale mode (minimum 1-1, maximum 3-5)"]
2398        );
2399        assert_eq!(
2400            declared_pool_errors(declared_autoscale, autoscale(1, 7)),
2401            vec!["Pool 'general' autoscale maximum 7 is outside the allowed range 2-6"]
2402        );
2403    }
2404
2405    #[test]
2406    fn local_plan_has_no_provider_machine_choices() {
2407        let stack = stack_with_container();
2408
2409        let plan = plan_compute(&stack, Platform::Local, None).expect("plan should build");
2410
2411        let pool = plan.pools.first().expect("general pool should exist");
2412        assert_eq!(pool.selected.machine(), None);
2413        assert!(pool.machines.is_empty());
2414        assert!(pool.errors.is_empty());
2415    }
2416}