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