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