Skip to main content

alien_core/
compute_planner.rs

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