Skip to main content

alien_core/
instance_catalog.rs

1//! Instance type catalog and selection algorithm for cloud compute infrastructure.
2//!
3//! This module provides:
4//! - A static catalog of known instance types across AWS, GCP, and Azure
5//! - Resource quantity parsing (CPU strings, Kubernetes-style memory/storage quantities)
6//! - An algorithm to select the optimal instance type for a given workload
7//!
8//! The catalog is the single source of truth for instance type specifications.
9//! It is used by the preflights system to automatically populate `CapacityGroup.instance_type`
10//! and `CapacityGroup.profile` based on the containers in a stack.
11
12use crate::{GpuSpec, MachineProfile, Platform};
13use serde::{Deserialize, Serialize};
14
15// ---------------------------------------------------------------------------
16// Resource quantity parsing
17// ---------------------------------------------------------------------------
18
19/// Parse a CPU quantity string to f64.
20///
21/// Accepts plain numbers ("1", "0.5", "2.0") and millicore suffixes ("500m" = 0.5).
22pub fn parse_cpu(s: &str) -> Result<f64, String> {
23    let s = s.trim();
24    if s.is_empty() {
25        return Err("empty CPU string".to_string());
26    }
27
28    if let Some(millis) = s.strip_suffix('m') {
29        let v: f64 = millis
30            .parse()
31            .map_err(|_| format!("invalid CPU millicore value: '{s}'"))?;
32        Ok(v / 1000.0)
33    } else {
34        s.parse().map_err(|_| format!("invalid CPU value: '{s}'"))
35    }
36}
37
38/// Parse a memory or storage quantity string to bytes.
39///
40/// Supports Kubernetes-style binary suffixes (Ki, Mi, Gi, Ti) and
41/// decimal suffixes (k, M, G, T). Plain numbers are interpreted as bytes.
42pub fn parse_memory_bytes(s: &str) -> Result<u64, String> {
43    let s = s.trim();
44    if s.is_empty() {
45        return Err("empty memory/storage string".to_string());
46    }
47
48    // Binary suffixes (powers of 1024)
49    if let Some(num) = s.strip_suffix("Ti") {
50        let v: f64 = num
51            .parse()
52            .map_err(|_| format!("invalid memory value: '{s}'"))?;
53        return Ok((v * 1024.0 * 1024.0 * 1024.0 * 1024.0) as u64);
54    }
55    if let Some(num) = s.strip_suffix("Gi") {
56        let v: f64 = num
57            .parse()
58            .map_err(|_| format!("invalid memory value: '{s}'"))?;
59        return Ok((v * 1024.0 * 1024.0 * 1024.0) as u64);
60    }
61    if let Some(num) = s.strip_suffix("Mi") {
62        let v: f64 = num
63            .parse()
64            .map_err(|_| format!("invalid memory value: '{s}'"))?;
65        return Ok((v * 1024.0 * 1024.0) as u64);
66    }
67    if let Some(num) = s.strip_suffix("Ki") {
68        let v: f64 = num
69            .parse()
70            .map_err(|_| format!("invalid memory value: '{s}'"))?;
71        return Ok((v * 1024.0) as u64);
72    }
73
74    // Decimal suffixes (powers of 1000)
75    if let Some(num) = s.strip_suffix('T') {
76        let v: f64 = num
77            .parse()
78            .map_err(|_| format!("invalid memory value: '{s}'"))?;
79        return Ok((v * 1_000_000_000_000.0) as u64);
80    }
81    if let Some(num) = s.strip_suffix('G') {
82        let v: f64 = num
83            .parse()
84            .map_err(|_| format!("invalid memory value: '{s}'"))?;
85        return Ok((v * 1_000_000_000.0) as u64);
86    }
87    if let Some(num) = s.strip_suffix('M') {
88        let v: f64 = num
89            .parse()
90            .map_err(|_| format!("invalid memory value: '{s}'"))?;
91        return Ok((v * 1_000_000.0) as u64);
92    }
93    if let Some(num) = s.strip_suffix('k') {
94        let v: f64 = num
95            .parse()
96            .map_err(|_| format!("invalid memory value: '{s}'"))?;
97        return Ok((v * 1000.0) as u64);
98    }
99
100    // Plain bytes
101    s.parse()
102        .map_err(|_| format!("invalid memory value: '{s}'"))
103}
104
105// ---------------------------------------------------------------------------
106// Instance type catalog
107// ---------------------------------------------------------------------------
108
109/// Instance family classification.
110#[derive(Debug, Clone, Copy, PartialEq, Eq)]
111pub enum InstanceFamily {
112    Burstable,
113    GeneralPurpose,
114    ComputeOptimized,
115    MemoryOptimized,
116    StorageOptimized,
117    GpuCompute,
118}
119
120/// CPU architecture.
121#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
122#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
123#[serde(rename_all = "snake_case")]
124pub enum Architecture {
125    Arm64,
126    X86_64,
127}
128
129/// Default machine architecture for images built for a managed cloud.
130pub fn default_architecture(platform: Platform) -> Option<Architecture> {
131    match platform {
132        Platform::Aws => Some(Architecture::Arm64),
133        Platform::Gcp | Platform::Azure => Some(Architecture::X86_64),
134        Platform::Kubernetes | Platform::Machines | Platform::Local | Platform::Test => None,
135    }
136}
137
138/// Static GPU specification for catalog entries (no heap allocation).
139#[derive(Debug, Clone, Copy, PartialEq, Eq)]
140pub struct CatalogGpu {
141    pub gpu_type: &'static str,
142    pub count: u32,
143}
144
145/// A known instance type with its hardware specifications.
146///
147/// All fields are compile-time constants. The catalog is a flat array of these.
148#[derive(Debug, Clone)]
149pub struct InstanceTypeSpec {
150    pub name: &'static str,
151    pub platform: Platform,
152    pub family: InstanceFamily,
153    pub architecture: Architecture,
154    /// vCPU count (hardware total)
155    pub vcpu: u32,
156    /// Memory in bytes (hardware total)
157    pub memory_bytes: u64,
158    /// Ephemeral storage in bytes (hardware total, NVMe for storage-optimized)
159    pub ephemeral_storage_bytes: u64,
160    /// GPU specification (for GPU instances)
161    pub gpu: Option<CatalogGpu>,
162}
163
164impl InstanceTypeSpec {
165    /// Whether ephemeral storage is a provider disk that can be sized at
166    /// deployment time instead of fixed local instance storage.
167    pub fn has_configurable_ephemeral_storage(&self) -> bool {
168        matches!(
169            self.family,
170            InstanceFamily::Burstable
171                | InstanceFamily::GeneralPurpose
172                | InstanceFamily::ComputeOptimized
173                | InstanceFamily::MemoryOptimized
174        )
175    }
176
177    /// Whether this instance type supports nested virtualization.
178    ///
179    /// Classify by documented provider families rather than adding a flag to
180    /// every catalog row. GCP still requires the instance template to opt in;
181    /// Azure exposes the capability automatically on supported VM sizes.
182    pub fn is_nested_virt_capable(&self) -> bool {
183        match self.platform {
184            Platform::Aws => {
185                let name = self.name;
186                name.starts_with("m8i.")
187                    || name.starts_with("c8i.")
188                    || name.starts_with("r8i.")
189                    || name.starts_with("m8i-flex.")
190                    || name.starts_with("c8i-flex.")
191                    || name.starts_with("r8i-flex.")
192            }
193            Platform::Gcp => self.name.starts_with("n2-standard-"),
194            Platform::Azure => {
195                let name = self.name;
196                (name.starts_with("Standard_D") && name.ends_with("s_v5"))
197                    || (name.starts_with("Standard_E") && name.ends_with("s_v5"))
198                    || (name.starts_with("Standard_F") && name.ends_with("s_v2"))
199            }
200            _ => false,
201        }
202    }
203
204    /// Convert this catalog entry into a `MachineProfile` for use in `CapacityGroup`.
205    pub fn to_machine_profile(&self) -> MachineProfile {
206        MachineProfile {
207            cpu: format!("{}.0", self.vcpu),
208            memory_bytes: self.memory_bytes,
209            ephemeral_storage_bytes: self.ephemeral_storage_bytes,
210            architecture: Some(self.architecture),
211            gpu: self.gpu.map(|g| GpuSpec {
212                gpu_type: g.gpu_type.to_string(),
213                count: g.count,
214            }),
215            data_disk: None,
216        }
217    }
218
219    /// Convert this entry to the profile controllers should provision for a
220    /// workload. Configurable cloud disks grow to the requested capacity;
221    /// fixed local disks retain their catalog capacity.
222    pub fn to_machine_profile_for_storage(&self, requested_bytes: u64) -> MachineProfile {
223        let mut profile = self.to_machine_profile();
224        if self.has_configurable_ephemeral_storage() {
225            profile.ephemeral_storage_bytes = profile.ephemeral_storage_bytes.max(requested_bytes);
226        }
227        profile
228    }
229}
230
231// Helpers for readable byte constants
232const KI: u64 = 1024;
233const MI: u64 = KI * 1024;
234const GI: u64 = MI * 1024;
235const TI: u64 = GI * 1024;
236
237/// Maximum workload storage that the current cloud controllers can turn into
238/// their provider-backed root disk after adding 25% filesystem headroom and
239/// 12 GiB of runtime overhead. Keep these bounds in sync with the provider
240/// controller contract; rejecting here is preferable to failing after cloud
241/// resources have already been created.
242pub fn max_configurable_ephemeral_storage_bytes(platform: Platform) -> Option<u64> {
243    let disk_limit_gib = match platform {
244        // AWS gp3 and GCP persistent disks currently top out at 64 TiB.
245        Platform::Aws | Platform::Gcp => 64 * 1024,
246        // Azure managed OS disks currently top out at 4,095 GiB.
247        Platform::Azure => 4_095,
248        Platform::Kubernetes | Platform::Machines | Platform::Local | Platform::Test => {
249            return None;
250        }
251    };
252
253    // Controllers first round the requested bytes up to a whole GiB, then
254    // round the 25% headroom up again. Returning a fractional-GiB bound would
255    // therefore admit `bound + 1 byte` and materialize a disk one GiB over the
256    // provider limit.
257    let max_requested_gib = (disk_limit_gib - 12) * 4 / 5;
258    Some(max_requested_gib * GI)
259}
260
261/// The complete instance type catalog.
262///
263/// This is the single source of truth for instance type specifications.
264/// Update this array when adding support for new instance types.
265///
266/// NOTE: Ephemeral storage values for non-NVMe instances are conservative defaults
267/// (EBS-backed root volumes). Storage-optimized instances list their NVMe capacity.
268static CATALOG: &[InstanceTypeSpec] = &[
269    // =========================================================================
270    // AWS — ARM (Graviton) preferred for cost efficiency
271    // =========================================================================
272
273    // Burstable (t4g — ARM Graviton2)
274    InstanceTypeSpec {
275        name: "t4g.micro",
276        platform: Platform::Aws,
277        family: InstanceFamily::Burstable,
278        architecture: Architecture::Arm64,
279        vcpu: 2,
280        memory_bytes: 1 * GI,
281        ephemeral_storage_bytes: 20 * GI,
282        gpu: None,
283    },
284    InstanceTypeSpec {
285        name: "t4g.small",
286        platform: Platform::Aws,
287        family: InstanceFamily::Burstable,
288        architecture: Architecture::Arm64,
289        vcpu: 2,
290        memory_bytes: 2 * GI,
291        ephemeral_storage_bytes: 20 * GI,
292        gpu: None,
293    },
294    InstanceTypeSpec {
295        name: "t4g.medium",
296        platform: Platform::Aws,
297        family: InstanceFamily::Burstable,
298        architecture: Architecture::Arm64,
299        vcpu: 2,
300        memory_bytes: 4 * GI,
301        ephemeral_storage_bytes: 20 * GI,
302        gpu: None,
303    },
304    InstanceTypeSpec {
305        name: "t4g.large",
306        platform: Platform::Aws,
307        family: InstanceFamily::Burstable,
308        architecture: Architecture::Arm64,
309        vcpu: 2,
310        memory_bytes: 8 * GI,
311        ephemeral_storage_bytes: 20 * GI,
312        gpu: None,
313    },
314    InstanceTypeSpec {
315        name: "t3.xlarge",
316        platform: Platform::Aws,
317        family: InstanceFamily::Burstable,
318        architecture: Architecture::X86_64,
319        vcpu: 4,
320        memory_bytes: 16 * GI,
321        ephemeral_storage_bytes: 20 * GI,
322        gpu: None,
323    },
324    InstanceTypeSpec {
325        name: "t4g.xlarge",
326        platform: Platform::Aws,
327        family: InstanceFamily::Burstable,
328        architecture: Architecture::Arm64,
329        vcpu: 4,
330        memory_bytes: 16 * GI,
331        ephemeral_storage_bytes: 20 * GI,
332        gpu: None,
333    },
334    // General Purpose (m7g — ARM Graviton3, up to 2xlarge / 8 vCPU)
335    InstanceTypeSpec {
336        name: "m7g.medium",
337        platform: Platform::Aws,
338        family: InstanceFamily::GeneralPurpose,
339        architecture: Architecture::Arm64,
340        vcpu: 1,
341        memory_bytes: 4 * GI,
342        ephemeral_storage_bytes: 20 * GI,
343        gpu: None,
344    },
345    InstanceTypeSpec {
346        name: "m7i.large",
347        platform: Platform::Aws,
348        family: InstanceFamily::GeneralPurpose,
349        architecture: Architecture::X86_64,
350        vcpu: 2,
351        memory_bytes: 8 * GI,
352        ephemeral_storage_bytes: 20 * GI,
353        gpu: None,
354    },
355    InstanceTypeSpec {
356        name: "m7g.large",
357        platform: Platform::Aws,
358        family: InstanceFamily::GeneralPurpose,
359        architecture: Architecture::Arm64,
360        vcpu: 2,
361        memory_bytes: 8 * GI,
362        ephemeral_storage_bytes: 20 * GI,
363        gpu: None,
364    },
365    // 8th-gen Intel AWS families accept
366    // `CpuOptions.NestedVirtualization=enabled`. The catalog filter in
367    // `select_instance_type` includes these entries only when the
368    // workload requests nested virt, so ordinary workloads continue to
369    // pick the cost-efficient Graviton (m7g) above. The pairwise
370    // interleave keeps the per-family vCPU-non-decreasing invariant
371    // (see `test_catalog_instance_types_sorted_by_vcpu_within_family`).
372    InstanceTypeSpec {
373        name: "m8i.large",
374        platform: Platform::Aws,
375        family: InstanceFamily::GeneralPurpose,
376        architecture: Architecture::X86_64,
377        vcpu: 2,
378        memory_bytes: 8 * GI,
379        ephemeral_storage_bytes: 20 * GI,
380        gpu: None,
381    },
382    InstanceTypeSpec {
383        name: "m7i.xlarge",
384        platform: Platform::Aws,
385        family: InstanceFamily::GeneralPurpose,
386        architecture: Architecture::X86_64,
387        vcpu: 4,
388        memory_bytes: 16 * GI,
389        ephemeral_storage_bytes: 20 * GI,
390        gpu: None,
391    },
392    InstanceTypeSpec {
393        name: "m7g.xlarge",
394        platform: Platform::Aws,
395        family: InstanceFamily::GeneralPurpose,
396        architecture: Architecture::Arm64,
397        vcpu: 4,
398        memory_bytes: 16 * GI,
399        ephemeral_storage_bytes: 20 * GI,
400        gpu: None,
401    },
402    InstanceTypeSpec {
403        name: "m8i.xlarge",
404        platform: Platform::Aws,
405        family: InstanceFamily::GeneralPurpose,
406        architecture: Architecture::X86_64,
407        vcpu: 4,
408        memory_bytes: 16 * GI,
409        ephemeral_storage_bytes: 20 * GI,
410        gpu: None,
411    },
412    InstanceTypeSpec {
413        name: "m7i.2xlarge",
414        platform: Platform::Aws,
415        family: InstanceFamily::GeneralPurpose,
416        architecture: Architecture::X86_64,
417        vcpu: 8,
418        memory_bytes: 32 * GI,
419        ephemeral_storage_bytes: 20 * GI,
420        gpu: None,
421    },
422    InstanceTypeSpec {
423        name: "m7g.2xlarge",
424        platform: Platform::Aws,
425        family: InstanceFamily::GeneralPurpose,
426        architecture: Architecture::Arm64,
427        vcpu: 8,
428        memory_bytes: 32 * GI,
429        ephemeral_storage_bytes: 20 * GI,
430        gpu: None,
431    },
432    InstanceTypeSpec {
433        name: "m8i.2xlarge",
434        platform: Platform::Aws,
435        family: InstanceFamily::GeneralPurpose,
436        architecture: Architecture::X86_64,
437        vcpu: 8,
438        memory_bytes: 32 * GI,
439        ephemeral_storage_bytes: 20 * GI,
440        gpu: None,
441    },
442    InstanceTypeSpec {
443        name: "m7i.4xlarge",
444        platform: Platform::Aws,
445        family: InstanceFamily::GeneralPurpose,
446        architecture: Architecture::X86_64,
447        vcpu: 16,
448        memory_bytes: 64 * GI,
449        ephemeral_storage_bytes: 20 * GI,
450        gpu: None,
451    },
452    InstanceTypeSpec {
453        name: "m7g.4xlarge",
454        platform: Platform::Aws,
455        family: InstanceFamily::GeneralPurpose,
456        architecture: Architecture::Arm64,
457        vcpu: 16,
458        memory_bytes: 64 * GI,
459        ephemeral_storage_bytes: 20 * GI,
460        gpu: None,
461    },
462    InstanceTypeSpec {
463        name: "m8i.4xlarge",
464        platform: Platform::Aws,
465        family: InstanceFamily::GeneralPurpose,
466        architecture: Architecture::X86_64,
467        vcpu: 16,
468        memory_bytes: 64 * GI,
469        ephemeral_storage_bytes: 20 * GI,
470        gpu: None,
471    },
472    // Compute Optimized (c7g — ARM Graviton3, c8g — ARM Graviton4)
473    InstanceTypeSpec {
474        name: "c7g.medium",
475        platform: Platform::Aws,
476        family: InstanceFamily::ComputeOptimized,
477        architecture: Architecture::Arm64,
478        vcpu: 1,
479        memory_bytes: 2 * GI,
480        ephemeral_storage_bytes: 20 * GI,
481        gpu: None,
482    },
483    InstanceTypeSpec {
484        name: "c8g.medium",
485        platform: Platform::Aws,
486        family: InstanceFamily::ComputeOptimized,
487        architecture: Architecture::Arm64,
488        vcpu: 1,
489        memory_bytes: 2 * GI,
490        ephemeral_storage_bytes: 20 * GI,
491        gpu: None,
492    },
493    InstanceTypeSpec {
494        name: "c7g.large",
495        platform: Platform::Aws,
496        family: InstanceFamily::ComputeOptimized,
497        architecture: Architecture::Arm64,
498        vcpu: 2,
499        memory_bytes: 4 * GI,
500        ephemeral_storage_bytes: 20 * GI,
501        gpu: None,
502    },
503    InstanceTypeSpec {
504        name: "c8g.large",
505        platform: Platform::Aws,
506        family: InstanceFamily::ComputeOptimized,
507        architecture: Architecture::Arm64,
508        vcpu: 2,
509        memory_bytes: 4 * GI,
510        ephemeral_storage_bytes: 20 * GI,
511        gpu: None,
512    },
513    InstanceTypeSpec {
514        name: "c8i.large",
515        platform: Platform::Aws,
516        family: InstanceFamily::ComputeOptimized,
517        architecture: Architecture::X86_64,
518        vcpu: 2,
519        memory_bytes: 4 * GI,
520        ephemeral_storage_bytes: 20 * GI,
521        gpu: None,
522    },
523    InstanceTypeSpec {
524        name: "c7g.xlarge",
525        platform: Platform::Aws,
526        family: InstanceFamily::ComputeOptimized,
527        architecture: Architecture::Arm64,
528        vcpu: 4,
529        memory_bytes: 8 * GI,
530        ephemeral_storage_bytes: 20 * GI,
531        gpu: None,
532    },
533    InstanceTypeSpec {
534        name: "c8g.xlarge",
535        platform: Platform::Aws,
536        family: InstanceFamily::ComputeOptimized,
537        architecture: Architecture::Arm64,
538        vcpu: 4,
539        memory_bytes: 8 * GI,
540        ephemeral_storage_bytes: 20 * GI,
541        gpu: None,
542    },
543    InstanceTypeSpec {
544        name: "c8i.xlarge",
545        platform: Platform::Aws,
546        family: InstanceFamily::ComputeOptimized,
547        architecture: Architecture::X86_64,
548        vcpu: 4,
549        memory_bytes: 8 * GI,
550        ephemeral_storage_bytes: 20 * GI,
551        gpu: None,
552    },
553    InstanceTypeSpec {
554        name: "c7g.2xlarge",
555        platform: Platform::Aws,
556        family: InstanceFamily::ComputeOptimized,
557        architecture: Architecture::Arm64,
558        vcpu: 8,
559        memory_bytes: 16 * GI,
560        ephemeral_storage_bytes: 20 * GI,
561        gpu: None,
562    },
563    InstanceTypeSpec {
564        name: "c8g.2xlarge",
565        platform: Platform::Aws,
566        family: InstanceFamily::ComputeOptimized,
567        architecture: Architecture::Arm64,
568        vcpu: 8,
569        memory_bytes: 16 * GI,
570        ephemeral_storage_bytes: 20 * GI,
571        gpu: None,
572    },
573    InstanceTypeSpec {
574        name: "c8i.2xlarge",
575        platform: Platform::Aws,
576        family: InstanceFamily::ComputeOptimized,
577        architecture: Architecture::X86_64,
578        vcpu: 8,
579        memory_bytes: 16 * GI,
580        ephemeral_storage_bytes: 20 * GI,
581        gpu: None,
582    },
583    InstanceTypeSpec {
584        name: "c7g.4xlarge",
585        platform: Platform::Aws,
586        family: InstanceFamily::ComputeOptimized,
587        architecture: Architecture::Arm64,
588        vcpu: 16,
589        memory_bytes: 32 * GI,
590        ephemeral_storage_bytes: 20 * GI,
591        gpu: None,
592    },
593    InstanceTypeSpec {
594        name: "c8g.4xlarge",
595        platform: Platform::Aws,
596        family: InstanceFamily::ComputeOptimized,
597        architecture: Architecture::Arm64,
598        vcpu: 16,
599        memory_bytes: 32 * GI,
600        ephemeral_storage_bytes: 20 * GI,
601        gpu: None,
602    },
603    InstanceTypeSpec {
604        name: "c8i.4xlarge",
605        platform: Platform::Aws,
606        family: InstanceFamily::ComputeOptimized,
607        architecture: Architecture::X86_64,
608        vcpu: 16,
609        memory_bytes: 32 * GI,
610        ephemeral_storage_bytes: 20 * GI,
611        gpu: None,
612    },
613    // Memory Optimized (r7g — ARM Graviton3, up to 2xlarge / 8 vCPU)
614    InstanceTypeSpec {
615        name: "r7g.medium",
616        platform: Platform::Aws,
617        family: InstanceFamily::MemoryOptimized,
618        architecture: Architecture::Arm64,
619        vcpu: 1,
620        memory_bytes: 8 * GI,
621        ephemeral_storage_bytes: 20 * GI,
622        gpu: None,
623    },
624    InstanceTypeSpec {
625        name: "r7g.large",
626        platform: Platform::Aws,
627        family: InstanceFamily::MemoryOptimized,
628        architecture: Architecture::Arm64,
629        vcpu: 2,
630        memory_bytes: 16 * GI,
631        ephemeral_storage_bytes: 20 * GI,
632        gpu: None,
633    },
634    InstanceTypeSpec {
635        name: "r7g.xlarge",
636        platform: Platform::Aws,
637        family: InstanceFamily::MemoryOptimized,
638        architecture: Architecture::Arm64,
639        vcpu: 4,
640        memory_bytes: 32 * GI,
641        ephemeral_storage_bytes: 20 * GI,
642        gpu: None,
643    },
644    InstanceTypeSpec {
645        name: "r7g.2xlarge",
646        platform: Platform::Aws,
647        family: InstanceFamily::MemoryOptimized,
648        architecture: Architecture::Arm64,
649        vcpu: 8,
650        memory_bytes: 64 * GI,
651        ephemeral_storage_bytes: 20 * GI,
652        gpu: None,
653    },
654    InstanceTypeSpec {
655        name: "r7g.4xlarge",
656        platform: Platform::Aws,
657        family: InstanceFamily::MemoryOptimized,
658        architecture: Architecture::Arm64,
659        vcpu: 16,
660        memory_bytes: 128 * GI,
661        ephemeral_storage_bytes: 20 * GI,
662        gpu: None,
663    },
664    // Storage Optimized (i4i — x86_64, NVMe)
665    InstanceTypeSpec {
666        name: "i4i.xlarge",
667        platform: Platform::Aws,
668        family: InstanceFamily::StorageOptimized,
669        architecture: Architecture::X86_64,
670        vcpu: 4,
671        memory_bytes: 32 * GI,
672        ephemeral_storage_bytes: 937 * GI,
673        gpu: None,
674    },
675    InstanceTypeSpec {
676        name: "i4i.2xlarge",
677        platform: Platform::Aws,
678        family: InstanceFamily::StorageOptimized,
679        architecture: Architecture::X86_64,
680        vcpu: 8,
681        memory_bytes: 64 * GI,
682        ephemeral_storage_bytes: 1875 * GI,
683        gpu: None,
684    },
685    InstanceTypeSpec {
686        name: "i4i.4xlarge",
687        platform: Platform::Aws,
688        family: InstanceFamily::StorageOptimized,
689        architecture: Architecture::X86_64,
690        vcpu: 16,
691        memory_bytes: 128 * GI,
692        ephemeral_storage_bytes: 3750 * GI,
693        gpu: None,
694    },
695    InstanceTypeSpec {
696        name: "i4i.8xlarge",
697        platform: Platform::Aws,
698        family: InstanceFamily::StorageOptimized,
699        architecture: Architecture::X86_64,
700        vcpu: 32,
701        memory_bytes: 256 * GI,
702        ephemeral_storage_bytes: 7500 * GI,
703        gpu: None,
704    },
705    // GPU — NVIDIA T4 (g5 — x86_64)
706    InstanceTypeSpec {
707        name: "g5.xlarge",
708        platform: Platform::Aws,
709        family: InstanceFamily::GpuCompute,
710        architecture: Architecture::X86_64,
711        vcpu: 4,
712        memory_bytes: 16 * GI,
713        ephemeral_storage_bytes: 250 * GI,
714        gpu: Some(CatalogGpu {
715            gpu_type: "nvidia-t4",
716            count: 1,
717        }),
718    },
719    InstanceTypeSpec {
720        name: "g5.2xlarge",
721        platform: Platform::Aws,
722        family: InstanceFamily::GpuCompute,
723        architecture: Architecture::X86_64,
724        vcpu: 8,
725        memory_bytes: 32 * GI,
726        ephemeral_storage_bytes: 450 * GI,
727        gpu: Some(CatalogGpu {
728            gpu_type: "nvidia-t4",
729            count: 1,
730        }),
731    },
732    // GPU — NVIDIA A100 (p4d — x86_64)
733    InstanceTypeSpec {
734        name: "p4d.24xlarge",
735        platform: Platform::Aws,
736        family: InstanceFamily::GpuCompute,
737        architecture: Architecture::X86_64,
738        vcpu: 96,
739        memory_bytes: 1152 * GI,
740        ephemeral_storage_bytes: 8000 * GI,
741        gpu: Some(CatalogGpu {
742            gpu_type: "nvidia-a100",
743            count: 8,
744        }),
745    },
746    // GPU — NVIDIA H100 (p5 — x86_64)
747    InstanceTypeSpec {
748        name: "p5.48xlarge",
749        platform: Platform::Aws,
750        family: InstanceFamily::GpuCompute,
751        architecture: Architecture::X86_64,
752        vcpu: 192,
753        memory_bytes: 2048 * GI,
754        ephemeral_storage_bytes: 8000 * GI,
755        gpu: Some(CatalogGpu {
756            gpu_type: "nvidia-h100",
757            count: 8,
758        }),
759    },
760    // =========================================================================
761    // GCP
762    // =========================================================================
763
764    // Burstable (e2)
765    InstanceTypeSpec {
766        name: "e2-micro",
767        platform: Platform::Gcp,
768        family: InstanceFamily::Burstable,
769        architecture: Architecture::X86_64,
770        vcpu: 2,
771        memory_bytes: 1 * GI,
772        ephemeral_storage_bytes: 20 * GI,
773        gpu: None,
774    },
775    InstanceTypeSpec {
776        name: "e2-small",
777        platform: Platform::Gcp,
778        family: InstanceFamily::Burstable,
779        architecture: Architecture::X86_64,
780        vcpu: 2,
781        memory_bytes: 2 * GI,
782        ephemeral_storage_bytes: 20 * GI,
783        gpu: None,
784    },
785    InstanceTypeSpec {
786        name: "e2-medium",
787        platform: Platform::Gcp,
788        family: InstanceFamily::Burstable,
789        architecture: Architecture::X86_64,
790        vcpu: 2,
791        memory_bytes: 4 * GI,
792        ephemeral_storage_bytes: 20 * GI,
793        gpu: None,
794    },
795    // General Purpose (n2-standard, up to 16 vCPU)
796    InstanceTypeSpec {
797        name: "n2-standard-2",
798        platform: Platform::Gcp,
799        family: InstanceFamily::GeneralPurpose,
800        architecture: Architecture::X86_64,
801        vcpu: 2,
802        memory_bytes: 8 * GI,
803        ephemeral_storage_bytes: 20 * GI,
804        gpu: None,
805    },
806    InstanceTypeSpec {
807        name: "n2-standard-4",
808        platform: Platform::Gcp,
809        family: InstanceFamily::GeneralPurpose,
810        architecture: Architecture::X86_64,
811        vcpu: 4,
812        memory_bytes: 16 * GI,
813        ephemeral_storage_bytes: 20 * GI,
814        gpu: None,
815    },
816    InstanceTypeSpec {
817        name: "n2-standard-8",
818        platform: Platform::Gcp,
819        family: InstanceFamily::GeneralPurpose,
820        architecture: Architecture::X86_64,
821        vcpu: 8,
822        memory_bytes: 32 * GI,
823        ephemeral_storage_bytes: 20 * GI,
824        gpu: None,
825    },
826    InstanceTypeSpec {
827        name: "n2-standard-16",
828        platform: Platform::Gcp,
829        family: InstanceFamily::GeneralPurpose,
830        architecture: Architecture::X86_64,
831        vcpu: 16,
832        memory_bytes: 64 * GI,
833        ephemeral_storage_bytes: 20 * GI,
834        gpu: None,
835    },
836    // Compute Optimized (c3-standard, up to 8 vCPU)
837    InstanceTypeSpec {
838        name: "c3-standard-4",
839        platform: Platform::Gcp,
840        family: InstanceFamily::ComputeOptimized,
841        architecture: Architecture::X86_64,
842        vcpu: 4,
843        memory_bytes: 8 * GI,
844        ephemeral_storage_bytes: 20 * GI,
845        gpu: None,
846    },
847    InstanceTypeSpec {
848        name: "c3-standard-8",
849        platform: Platform::Gcp,
850        family: InstanceFamily::ComputeOptimized,
851        architecture: Architecture::X86_64,
852        vcpu: 8,
853        memory_bytes: 16 * GI,
854        ephemeral_storage_bytes: 20 * GI,
855        gpu: None,
856    },
857    // Memory Optimized (n2-highmem, up to 8 vCPU)
858    InstanceTypeSpec {
859        name: "n2-highmem-2",
860        platform: Platform::Gcp,
861        family: InstanceFamily::MemoryOptimized,
862        architecture: Architecture::X86_64,
863        vcpu: 2,
864        memory_bytes: 16 * GI,
865        ephemeral_storage_bytes: 20 * GI,
866        gpu: None,
867    },
868    InstanceTypeSpec {
869        name: "n2-highmem-4",
870        platform: Platform::Gcp,
871        family: InstanceFamily::MemoryOptimized,
872        architecture: Architecture::X86_64,
873        vcpu: 4,
874        memory_bytes: 32 * GI,
875        ephemeral_storage_bytes: 20 * GI,
876        gpu: None,
877    },
878    InstanceTypeSpec {
879        name: "n2-highmem-8",
880        platform: Platform::Gcp,
881        family: InstanceFamily::MemoryOptimized,
882        architecture: Architecture::X86_64,
883        vcpu: 8,
884        memory_bytes: 64 * GI,
885        ephemeral_storage_bytes: 20 * GI,
886        gpu: None,
887    },
888    InstanceTypeSpec {
889        name: "n2-highmem-16",
890        platform: Platform::Gcp,
891        family: InstanceFamily::MemoryOptimized,
892        architecture: Architecture::X86_64,
893        vcpu: 16,
894        memory_bytes: 128 * GI,
895        ephemeral_storage_bytes: 20 * GI,
896        gpu: None,
897    },
898    InstanceTypeSpec {
899        name: "n2-highmem-32",
900        platform: Platform::Gcp,
901        family: InstanceFamily::MemoryOptimized,
902        architecture: Architecture::X86_64,
903        vcpu: 32,
904        memory_bytes: 256 * GI,
905        ephemeral_storage_bytes: 20 * GI,
906        gpu: None,
907    },
908    // Storage Optimized (c3d-standard with local SSD)
909    InstanceTypeSpec {
910        name: "c3d-standard-8",
911        platform: Platform::Gcp,
912        family: InstanceFamily::StorageOptimized,
913        architecture: Architecture::X86_64,
914        vcpu: 8,
915        memory_bytes: 32 * GI,
916        ephemeral_storage_bytes: 480 * GI,
917        gpu: None,
918    },
919    InstanceTypeSpec {
920        name: "c3d-standard-16",
921        platform: Platform::Gcp,
922        family: InstanceFamily::StorageOptimized,
923        architecture: Architecture::X86_64,
924        vcpu: 16,
925        memory_bytes: 64 * GI,
926        ephemeral_storage_bytes: 960 * GI,
927        gpu: None,
928    },
929    InstanceTypeSpec {
930        name: "c3d-standard-30",
931        platform: Platform::Gcp,
932        family: InstanceFamily::StorageOptimized,
933        architecture: Architecture::X86_64,
934        vcpu: 30,
935        memory_bytes: 120 * GI,
936        ephemeral_storage_bytes: 1920 * GI,
937        gpu: None,
938    },
939    // GPU — NVIDIA T4 (n1-standard + T4)
940    InstanceTypeSpec {
941        name: "n1-standard-4-t4",
942        platform: Platform::Gcp,
943        family: InstanceFamily::GpuCompute,
944        architecture: Architecture::X86_64,
945        vcpu: 4,
946        memory_bytes: 15 * GI,
947        ephemeral_storage_bytes: 100 * GI,
948        gpu: Some(CatalogGpu {
949            gpu_type: "nvidia-t4",
950            count: 1,
951        }),
952    },
953    // GPU — NVIDIA A100 (a2-highgpu)
954    InstanceTypeSpec {
955        name: "a2-highgpu-1g",
956        platform: Platform::Gcp,
957        family: InstanceFamily::GpuCompute,
958        architecture: Architecture::X86_64,
959        vcpu: 12,
960        memory_bytes: 85 * GI,
961        ephemeral_storage_bytes: 100 * GI,
962        gpu: Some(CatalogGpu {
963            gpu_type: "nvidia-a100",
964            count: 1,
965        }),
966    },
967    InstanceTypeSpec {
968        name: "a2-highgpu-8g",
969        platform: Platform::Gcp,
970        family: InstanceFamily::GpuCompute,
971        architecture: Architecture::X86_64,
972        vcpu: 96,
973        memory_bytes: 1360 * GI,
974        ephemeral_storage_bytes: 100 * GI,
975        gpu: Some(CatalogGpu {
976            gpu_type: "nvidia-a100",
977            count: 8,
978        }),
979    },
980    // GPU — NVIDIA H100 (a3-highgpu)
981    InstanceTypeSpec {
982        name: "a3-highgpu-8g",
983        platform: Platform::Gcp,
984        family: InstanceFamily::GpuCompute,
985        architecture: Architecture::X86_64,
986        vcpu: 208,
987        memory_bytes: 1872 * GI,
988        ephemeral_storage_bytes: 100 * GI,
989        gpu: Some(CatalogGpu {
990            gpu_type: "nvidia-h100",
991            count: 8,
992        }),
993    },
994    // =========================================================================
995    // Azure
996    // =========================================================================
997
998    // Burstable (B-series v2)
999    InstanceTypeSpec {
1000        name: "Standard_B1s",
1001        platform: Platform::Azure,
1002        family: InstanceFamily::Burstable,
1003        architecture: Architecture::X86_64,
1004        vcpu: 1,
1005        memory_bytes: 1 * GI,
1006        ephemeral_storage_bytes: 20 * GI,
1007        gpu: None,
1008    },
1009    InstanceTypeSpec {
1010        name: "Standard_B2s",
1011        platform: Platform::Azure,
1012        family: InstanceFamily::Burstable,
1013        architecture: Architecture::X86_64,
1014        vcpu: 2,
1015        memory_bytes: 4 * GI,
1016        ephemeral_storage_bytes: 20 * GI,
1017        gpu: None,
1018    },
1019    InstanceTypeSpec {
1020        name: "Standard_B2ms",
1021        platform: Platform::Azure,
1022        family: InstanceFamily::Burstable,
1023        architecture: Architecture::X86_64,
1024        vcpu: 2,
1025        memory_bytes: 8 * GI,
1026        ephemeral_storage_bytes: 20 * GI,
1027        gpu: None,
1028    },
1029    InstanceTypeSpec {
1030        name: "Standard_B4ms",
1031        platform: Platform::Azure,
1032        family: InstanceFamily::Burstable,
1033        architecture: Architecture::X86_64,
1034        vcpu: 4,
1035        memory_bytes: 16 * GI,
1036        ephemeral_storage_bytes: 20 * GI,
1037        gpu: None,
1038    },
1039    // General Purpose (Dsv4/Dsv5-series, up to 16 vCPU)
1040    InstanceTypeSpec {
1041        name: "Standard_D2s_v4",
1042        platform: Platform::Azure,
1043        family: InstanceFamily::GeneralPurpose,
1044        architecture: Architecture::X86_64,
1045        vcpu: 2,
1046        memory_bytes: 8 * GI,
1047        ephemeral_storage_bytes: 20 * GI,
1048        gpu: None,
1049    },
1050    InstanceTypeSpec {
1051        name: "Standard_D2s_v5",
1052        platform: Platform::Azure,
1053        family: InstanceFamily::GeneralPurpose,
1054        architecture: Architecture::X86_64,
1055        vcpu: 2,
1056        memory_bytes: 8 * GI,
1057        ephemeral_storage_bytes: 20 * GI,
1058        gpu: None,
1059    },
1060    InstanceTypeSpec {
1061        name: "Standard_D4s_v5",
1062        platform: Platform::Azure,
1063        family: InstanceFamily::GeneralPurpose,
1064        architecture: Architecture::X86_64,
1065        vcpu: 4,
1066        memory_bytes: 16 * GI,
1067        ephemeral_storage_bytes: 20 * GI,
1068        gpu: None,
1069    },
1070    InstanceTypeSpec {
1071        name: "Standard_D8s_v5",
1072        platform: Platform::Azure,
1073        family: InstanceFamily::GeneralPurpose,
1074        architecture: Architecture::X86_64,
1075        vcpu: 8,
1076        memory_bytes: 32 * GI,
1077        ephemeral_storage_bytes: 20 * GI,
1078        gpu: None,
1079    },
1080    InstanceTypeSpec {
1081        name: "Standard_D16s_v5",
1082        platform: Platform::Azure,
1083        family: InstanceFamily::GeneralPurpose,
1084        architecture: Architecture::X86_64,
1085        vcpu: 16,
1086        memory_bytes: 64 * GI,
1087        ephemeral_storage_bytes: 20 * GI,
1088        gpu: None,
1089    },
1090    // Compute Optimized (Fv2-series, up to 16 vCPU)
1091    InstanceTypeSpec {
1092        name: "Standard_F2s_v2",
1093        platform: Platform::Azure,
1094        family: InstanceFamily::ComputeOptimized,
1095        architecture: Architecture::X86_64,
1096        vcpu: 2,
1097        memory_bytes: 4 * GI,
1098        ephemeral_storage_bytes: 20 * GI,
1099        gpu: None,
1100    },
1101    InstanceTypeSpec {
1102        name: "Standard_F4s_v2",
1103        platform: Platform::Azure,
1104        family: InstanceFamily::ComputeOptimized,
1105        architecture: Architecture::X86_64,
1106        vcpu: 4,
1107        memory_bytes: 8 * GI,
1108        ephemeral_storage_bytes: 20 * GI,
1109        gpu: None,
1110    },
1111    InstanceTypeSpec {
1112        name: "Standard_F8s_v2",
1113        platform: Platform::Azure,
1114        family: InstanceFamily::ComputeOptimized,
1115        architecture: Architecture::X86_64,
1116        vcpu: 8,
1117        memory_bytes: 16 * GI,
1118        ephemeral_storage_bytes: 20 * GI,
1119        gpu: None,
1120    },
1121    InstanceTypeSpec {
1122        name: "Standard_F16s_v2",
1123        platform: Platform::Azure,
1124        family: InstanceFamily::ComputeOptimized,
1125        architecture: Architecture::X86_64,
1126        vcpu: 16,
1127        memory_bytes: 32 * GI,
1128        ephemeral_storage_bytes: 20 * GI,
1129        gpu: None,
1130    },
1131    // Memory Optimized (Ev5-series, up to 16 vCPU)
1132    InstanceTypeSpec {
1133        name: "Standard_E2s_v5",
1134        platform: Platform::Azure,
1135        family: InstanceFamily::MemoryOptimized,
1136        architecture: Architecture::X86_64,
1137        vcpu: 2,
1138        memory_bytes: 16 * GI,
1139        ephemeral_storage_bytes: 20 * GI,
1140        gpu: None,
1141    },
1142    InstanceTypeSpec {
1143        name: "Standard_E4s_v5",
1144        platform: Platform::Azure,
1145        family: InstanceFamily::MemoryOptimized,
1146        architecture: Architecture::X86_64,
1147        vcpu: 4,
1148        memory_bytes: 32 * GI,
1149        ephemeral_storage_bytes: 20 * GI,
1150        gpu: None,
1151    },
1152    InstanceTypeSpec {
1153        name: "Standard_E8s_v5",
1154        platform: Platform::Azure,
1155        family: InstanceFamily::MemoryOptimized,
1156        architecture: Architecture::X86_64,
1157        vcpu: 8,
1158        memory_bytes: 64 * GI,
1159        ephemeral_storage_bytes: 20 * GI,
1160        gpu: None,
1161    },
1162    InstanceTypeSpec {
1163        name: "Standard_E16s_v5",
1164        platform: Platform::Azure,
1165        family: InstanceFamily::MemoryOptimized,
1166        architecture: Architecture::X86_64,
1167        vcpu: 16,
1168        memory_bytes: 128 * GI,
1169        ephemeral_storage_bytes: 20 * GI,
1170        gpu: None,
1171    },
1172    // Storage Optimized (Lsv3-series with NVMe)
1173    InstanceTypeSpec {
1174        name: "Standard_L8s_v3",
1175        platform: Platform::Azure,
1176        family: InstanceFamily::StorageOptimized,
1177        architecture: Architecture::X86_64,
1178        vcpu: 8,
1179        memory_bytes: 64 * GI,
1180        ephemeral_storage_bytes: 1788 * GI,
1181        gpu: None,
1182    },
1183    InstanceTypeSpec {
1184        name: "Standard_L16s_v3",
1185        platform: Platform::Azure,
1186        family: InstanceFamily::StorageOptimized,
1187        architecture: Architecture::X86_64,
1188        vcpu: 16,
1189        memory_bytes: 128 * GI,
1190        ephemeral_storage_bytes: 3576 * GI,
1191        gpu: None,
1192    },
1193    InstanceTypeSpec {
1194        name: "Standard_L32s_v3",
1195        platform: Platform::Azure,
1196        family: InstanceFamily::StorageOptimized,
1197        architecture: Architecture::X86_64,
1198        vcpu: 32,
1199        memory_bytes: 256 * GI,
1200        ephemeral_storage_bytes: 7154 * GI,
1201        gpu: None,
1202    },
1203    // GPU — NVIDIA T4 (NCasT4_v3-series)
1204    InstanceTypeSpec {
1205        name: "Standard_NC4as_T4_v3",
1206        platform: Platform::Azure,
1207        family: InstanceFamily::GpuCompute,
1208        architecture: Architecture::X86_64,
1209        vcpu: 4,
1210        memory_bytes: 28 * GI,
1211        ephemeral_storage_bytes: 176 * GI,
1212        gpu: Some(CatalogGpu {
1213            gpu_type: "nvidia-t4",
1214            count: 1,
1215        }),
1216    },
1217    // GPU — NVIDIA A100 (NC A100 v4-series)
1218    InstanceTypeSpec {
1219        name: "Standard_NC24ads_A100_v4",
1220        platform: Platform::Azure,
1221        family: InstanceFamily::GpuCompute,
1222        architecture: Architecture::X86_64,
1223        vcpu: 24,
1224        memory_bytes: 220 * GI,
1225        ephemeral_storage_bytes: 958 * GI,
1226        gpu: Some(CatalogGpu {
1227            gpu_type: "nvidia-a100",
1228            count: 1,
1229        }),
1230    },
1231    InstanceTypeSpec {
1232        name: "Standard_NC96ads_A100_v4",
1233        platform: Platform::Azure,
1234        family: InstanceFamily::GpuCompute,
1235        architecture: Architecture::X86_64,
1236        vcpu: 96,
1237        memory_bytes: 880 * GI,
1238        ephemeral_storage_bytes: 3916 * GI,
1239        gpu: Some(CatalogGpu {
1240            gpu_type: "nvidia-a100",
1241            count: 4,
1242        }),
1243    },
1244    // GPU — NVIDIA H100 (ND H100 v5-series)
1245    InstanceTypeSpec {
1246        name: "Standard_ND96isr_H100_v5",
1247        platform: Platform::Azure,
1248        family: InstanceFamily::GpuCompute,
1249        architecture: Architecture::X86_64,
1250        vcpu: 96,
1251        memory_bytes: 1900 * GI,
1252        ephemeral_storage_bytes: 1000 * GI,
1253        gpu: Some(CatalogGpu {
1254            gpu_type: "nvidia-h100",
1255            count: 8,
1256        }),
1257    },
1258];
1259
1260// ---------------------------------------------------------------------------
1261// Catalog lookup
1262// ---------------------------------------------------------------------------
1263
1264/// Get all instance types for a given platform.
1265pub fn catalog_for_platform(platform: Platform) -> Vec<&'static InstanceTypeSpec> {
1266    CATALOG
1267        .iter()
1268        .filter(|spec| spec.platform == platform)
1269        .collect()
1270}
1271
1272/// Find a specific instance type by name and platform.
1273pub fn find_instance_type(platform: Platform, name: &str) -> Option<&'static InstanceTypeSpec> {
1274    CATALOG
1275        .iter()
1276        .find(|spec| spec.platform == platform && spec.name == name)
1277}
1278
1279/// Whether a capacity group may move from AWS machine `old` to `new` without setup: both are
1280/// catalog machines of one CPU architecture, so the stack's images still run on the new one.
1281pub fn is_same_architecture_aws_machine(old: &str, new: &str) -> bool {
1282    is_same_architecture_machine(Platform::Aws, old, new)
1283}
1284
1285/// Whether two catalog machines belong to the given cloud and share a CPU architecture.
1286pub fn is_same_architecture_machine(platform: Platform, old: &str, new: &str) -> bool {
1287    matches!(platform, Platform::Aws | Platform::Gcp | Platform::Azure)
1288        && find_instance_type(platform, old)
1289            .zip(find_instance_type(platform, new))
1290            .is_some_and(|(old, new)| old.architecture == new.architecture)
1291}
1292
1293// ---------------------------------------------------------------------------
1294// Instance type selection
1295// ---------------------------------------------------------------------------
1296
1297/// Aggregated resource requirements from all containers in a capacity group.
1298#[derive(Debug, Clone)]
1299pub struct WorkloadRequirements {
1300    /// Total CPU needed at desired scale (sum of desired CPU * desired_replicas per container)
1301    pub total_cpu_at_desired: f64,
1302    /// Total memory needed at desired scale (sum of desired memory * desired_replicas per container)
1303    pub total_memory_bytes_at_desired: u64,
1304    /// Total CPU needed at maximum scale (sum of desired CPU * max_replicas per container)
1305    pub total_cpu_at_max: f64,
1306    /// Total memory needed at maximum scale (sum of desired memory * max_replicas per container)
1307    pub total_memory_bytes_at_max: u64,
1308    /// Largest CPU request among all individual containers (single replica)
1309    pub max_cpu_per_container: f64,
1310    /// Largest memory request among all individual containers (single replica)
1311    pub max_memory_per_container: u64,
1312    /// Maximum ephemeral storage any single container requires
1313    pub max_ephemeral_storage_bytes: u64,
1314    /// GPU requirement (if any container needs GPU)
1315    pub gpu: Option<GpuSpec>,
1316    /// Required CPU architecture, when source explicitly constrains it.
1317    pub architecture: Option<Architecture>,
1318    /// If true, only instance types that expose nested virtualization (VT-x/EPT)
1319    /// to guest VMs are eligible. Required by workloads that run QEMU/KVM
1320    /// inside a container.
1321    pub nested_virt: bool,
1322}
1323
1324/// Result of instance type selection.
1325#[derive(Debug, Clone)]
1326pub struct InstanceSelection {
1327    /// Selected instance type name (e.g., "m7g.2xlarge")
1328    pub instance_type: &'static str,
1329    /// Machine profile derived from the instance type
1330    pub profile: MachineProfile,
1331    /// Recommended minimum number of machines
1332    pub min_machines: u32,
1333    /// Recommended maximum number of machines
1334    pub max_machines: u32,
1335}
1336
1337/// Ephemeral storage threshold above which storage-optimized instances are selected.
1338const STORAGE_OPTIMIZED_THRESHOLD: u64 = 200 * GI;
1339
1340/// Maximum number of machines per cluster.
1341const MAX_MACHINES_PER_CLUSTER: u32 = 10;
1342
1343/// Preferred vCPU cap for non-GPU/non-storage workloads. A larger indivisible
1344/// allocation or bounded fleet can require a bigger machine.
1345const MAX_STANDARD_VCPU: u32 = 8;
1346
1347/// Runtime CPU reserved for system processes on each managed container machine.
1348const SYSTEM_RESERVE_CPU: f64 = 0.5;
1349
1350/// Runtime planning headroom for total desired/max workload.
1351const WORKLOAD_HEADROOM_FACTOR: f64 = 1.15;
1352
1353/// Select the best instance type for a workload on a given platform.
1354///
1355/// The algorithm:
1356/// 1. GPU workloads: Match by GPU type, find smallest instance with enough GPUs.
1357/// 2. Storage-heavy workloads (>200Gi ephemeral): Use storage-optimized instances.
1358/// 3. All other workloads: Fit the allocation with system reserve and headroom,
1359///    preferring at most 8 vCPUs unless the allocation requires more. Use the
1360///    workload's preferred family, with general-purpose fallback when needed.
1361///
1362/// Returns an error if no suitable instance type is found.
1363pub fn select_instance_type(
1364    platform: Platform,
1365    requirements: &WorkloadRequirements,
1366) -> Result<InstanceSelection, String> {
1367    let family = select_family(requirements);
1368    let family = if requirements.nested_virt && family == InstanceFamily::Burstable {
1369        InstanceFamily::GeneralPurpose
1370    } else {
1371        family
1372    };
1373    let selection = select_instance_type_in_family(platform, requirements, family);
1374    if selection.is_err() && family == InstanceFamily::Burstable {
1375        return select_instance_type_in_family(
1376            platform,
1377            requirements,
1378            InstanceFamily::GeneralPurpose,
1379        );
1380    }
1381    selection
1382}
1383
1384fn select_instance_type_in_family(
1385    platform: Platform,
1386    requirements: &WorkloadRequirements,
1387    family: InstanceFamily,
1388) -> Result<InstanceSelection, String> {
1389    let architecture = requirements
1390        .architecture
1391        .or_else(|| default_architecture(platform))
1392        .ok_or_else(|| format!("platform {platform} has no default compute architecture"))?;
1393
1394    let candidates: Vec<&InstanceTypeSpec> = CATALOG
1395        .iter()
1396        .filter(|spec| spec.platform == platform && spec.family == family)
1397        .filter(|spec| {
1398            if requirements.nested_virt {
1399                spec.is_nested_virt_capable()
1400            } else {
1401                platform != Platform::Aws || !spec.is_nested_virt_capable()
1402            }
1403        })
1404        .filter(|spec| spec.architecture == architecture)
1405        .collect();
1406
1407    // A storage-heavy workload may have no fixed-local candidate for the
1408    // requested architecture/nested-virtualization contract. That is exactly
1409    // when we must consider a provider-backed disk on a general-purpose VM;
1410    // rejecting here made the fallback below unreachable.
1411    if candidates.is_empty() && family != InstanceFamily::StorageOptimized {
1412        let family_has_other_architecture = CATALOG.iter().any(|spec| {
1413            spec.platform == platform
1414                && spec.family == family
1415                && if requirements.nested_virt {
1416                    spec.is_nested_virt_capable()
1417                } else {
1418                    platform != Platform::Aws || !spec.is_nested_virt_capable()
1419                }
1420        });
1421        if family_has_other_architecture {
1422            return Err(format!(
1423                "architecture {architecture:?} is unavailable for this workload on platform {platform}"
1424            ));
1425        }
1426        return Err(if requirements.nested_virt {
1427            format!(
1428                "no nested-virt-capable {family:?} instance types in catalog for platform {platform}"
1429            )
1430        } else {
1431            format!("no {family:?} instance types in catalog for platform {platform}")
1432        });
1433    }
1434
1435    // For GPU workloads, filter by GPU type
1436    let candidates = if let Some(ref gpu) = requirements.gpu {
1437        let filtered: Vec<&InstanceTypeSpec> = candidates
1438            .into_iter()
1439            .filter(|spec| {
1440                spec.gpu.as_ref().map_or(false, |g| {
1441                    g.gpu_type == gpu.gpu_type && g.count >= gpu.count
1442                })
1443            })
1444            .collect();
1445        if filtered.is_empty() {
1446            return Err(format!(
1447                "no instance type for GPU type '{}' x{} on platform {platform}",
1448                gpu.gpu_type, gpu.count
1449            ));
1450        }
1451        filtered
1452    } else {
1453        candidates
1454    };
1455
1456    // For storage workloads, filter by ephemeral storage capacity
1457    let candidates = if family == InstanceFamily::StorageOptimized {
1458        let filtered: Vec<&InstanceTypeSpec> = candidates
1459            .into_iter()
1460            .filter(|spec| spec.ephemeral_storage_bytes >= requirements.max_ephemeral_storage_bytes)
1461            .collect();
1462        if filtered.is_empty() {
1463            // Fixed-local NVMe is preferred for large requests, but provider
1464            // disks on ordinary cloud machines remain configurable. Fall back
1465            // when no fixed-local catalog entry can satisfy the request.
1466            CATALOG
1467                .iter()
1468                .filter(|spec| {
1469                    spec.platform == platform
1470                        && spec.family == InstanceFamily::GeneralPurpose
1471                        && spec.has_configurable_ephemeral_storage()
1472                        && max_configurable_ephemeral_storage_bytes(platform)
1473                            .is_some_and(|max| requirements.max_ephemeral_storage_bytes <= max)
1474                })
1475                .filter(|spec| {
1476                    if requirements.nested_virt {
1477                        spec.is_nested_virt_capable()
1478                    } else {
1479                        platform != Platform::Aws || !spec.is_nested_virt_capable()
1480                    }
1481                })
1482                .filter(|spec| spec.architecture == architecture)
1483                .collect()
1484        } else {
1485            filtered
1486        }
1487    } else {
1488        candidates
1489    };
1490
1491    if candidates.is_empty() {
1492        return Err(format!(
1493            "architecture {architecture:?} is unavailable for this workload on platform {platform}"
1494        ));
1495    }
1496
1497    // More machines cannot split one replica. Every candidate must fit its
1498    // largest allocation after host reserve and workload headroom.
1499    let candidates: Vec<_> = candidates
1500        .into_iter()
1501        .filter(|spec| {
1502            allocatable_cpu(spec) >= requirements.max_cpu_per_container * WORKLOAD_HEADROOM_FACTOR
1503                && allocatable_memory_bytes(spec) as f64
1504                    >= requirements.max_memory_per_container as f64 * WORKLOAD_HEADROOM_FACTOR
1505        })
1506        .collect();
1507    let smallest_replica_machine = candidates.iter().map(|spec| spec.vcpu).min()
1508        .ok_or_else(|| format!("no {platform} machine can fit one replica with system reserve and workload headroom"))?;
1509
1510    // Apply the policy for the candidates we will actually select from. A
1511    // storage-heavy request can fall back from fixed-local storage machines to
1512    // general-purpose machines with provider-backed disks; those machines must
1513    // retain the normal horizontal-scaling cap.
1514    let effective_family = candidates[0].family;
1515    let vcpu_cap = if effective_family == InstanceFamily::GpuCompute
1516        || effective_family == InstanceFamily::StorageOptimized
1517    {
1518        u32::MAX
1519    } else {
1520        MAX_STANDARD_VCPU.max(smallest_replica_machine)
1521    };
1522
1523    let desired_target_machines = desired_target_machines(requirements);
1524    let target_cpu = requirements
1525        .max_cpu_per_container
1526        .max(requirements.total_cpu_at_desired / desired_target_machines as f64)
1527        * WORKLOAD_HEADROOM_FACTOR;
1528    let target_memory = (requirements.max_memory_per_container as f64)
1529        .max(requirements.total_memory_bytes_at_desired as f64 / desired_target_machines as f64)
1530        * WORKLOAD_HEADROOM_FACTOR;
1531
1532    // Find the smallest instance whose allocatable capacity meets the workload
1533    // target after host reserve and workload headroom. Machine count already
1534    // accounts for multiple replicas; requiring space for an arbitrary second
1535    // copy here would size the same demand twice.
1536    let selected = candidates
1537        .iter()
1538        .filter(|spec| {
1539            spec.vcpu <= vcpu_cap
1540                && allocatable_cpu(spec) >= target_cpu
1541                && allocatable_memory_bytes(spec) as f64 >= target_memory
1542        })
1543        .min_by_key(|spec| spec.vcpu)
1544        .or_else(|| {
1545            // If nothing fits within the cap, pick the largest instance under the cap
1546            candidates
1547                .iter()
1548                .filter(|spec| spec.vcpu <= vcpu_cap)
1549                .max_by_key(|spec| spec.vcpu)
1550        })
1551        .or_else(|| {
1552            // Last resort: pick the smallest available instance (for GPU/storage)
1553            candidates.iter().min_by_key(|spec| spec.vcpu)
1554        })
1555        .ok_or_else(|| format!("no instance types available for platform {platform}"))?;
1556
1557    // Calculate machine counts
1558    let max_machines = compute_max_machines(requirements, selected);
1559    let min_machines = compute_min_machines(requirements, selected, max_machines);
1560
1561    Ok(InstanceSelection {
1562        instance_type: selected.name,
1563        profile: selected.to_machine_profile_for_storage(requirements.max_ephemeral_storage_bytes),
1564        min_machines,
1565        max_machines,
1566    })
1567}
1568
1569/// Select instance family based on workload characteristics.
1570///
1571/// Uses GeneralPurpose for all standard workloads — widely available across
1572/// regions and cost-effective. Only specialized workloads (GPU, large ephemeral
1573/// storage) get specialized families. Very small workloads get burstable.
1574pub fn select_family(requirements: &WorkloadRequirements) -> InstanceFamily {
1575    // GPU workloads always get GPU instances
1576    if requirements.gpu.is_some() {
1577        return InstanceFamily::GpuCompute;
1578    }
1579
1580    // Large ephemeral storage needs NVMe (storage-optimized)
1581    if requirements.max_ephemeral_storage_bytes > STORAGE_OPTIMIZED_THRESHOLD {
1582        return InstanceFamily::StorageOptimized;
1583    }
1584
1585    // Very small workloads use burstable instances
1586    if requirements.total_cpu_at_max < 2.0 {
1587        return InstanceFamily::Burstable;
1588    }
1589
1590    // All other workloads use GeneralPurpose — available everywhere, good pricing
1591    InstanceFamily::GeneralPurpose
1592}
1593
1594/// Calculate maximum machines needed to fit the workload with headroom.
1595fn compute_max_machines(requirements: &WorkloadRequirements, instance: &InstanceTypeSpec) -> u32 {
1596    let cpu_with_headroom = requirements.total_cpu_at_max * WORKLOAD_HEADROOM_FACTOR;
1597    let cpu_machines = (cpu_with_headroom / allocatable_cpu(instance)).ceil() as u32;
1598
1599    let mem_with_headroom =
1600        requirements.total_memory_bytes_at_max as f64 * WORKLOAD_HEADROOM_FACTOR;
1601    let mem_machines =
1602        (mem_with_headroom / allocatable_memory_bytes(instance) as f64).ceil() as u32;
1603
1604    // Take the larger of CPU-based and memory-based, clamped to cluster limit
1605    cpu_machines
1606        .max(mem_machines)
1607        .max(1)
1608        .min(MAX_MACHINES_PER_CLUSTER)
1609}
1610
1611/// Calculate minimum machines for HA.
1612fn compute_min_machines(
1613    requirements: &WorkloadRequirements,
1614    instance: &InstanceTypeSpec,
1615    max_machines: u32,
1616) -> u32 {
1617    let cpu_with_headroom = requirements.total_cpu_at_desired * WORKLOAD_HEADROOM_FACTOR;
1618    let cpu_machines = (cpu_with_headroom / allocatable_cpu(instance)).ceil() as u32;
1619
1620    let mem_with_headroom =
1621        requirements.total_memory_bytes_at_desired as f64 * WORKLOAD_HEADROOM_FACTOR;
1622    let mem_machines =
1623        (mem_with_headroom / allocatable_memory_bytes(instance) as f64).ceil() as u32;
1624
1625    cpu_machines
1626        .max(mem_machines)
1627        .max(1)
1628        .min(2)
1629        .min(max_machines)
1630}
1631
1632fn desired_target_machines(requirements: &WorkloadRequirements) -> u32 {
1633    if requirements.total_cpu_at_desired >= 2.0
1634        || requirements.total_memory_bytes_at_desired >= 4 * GI
1635    {
1636        2
1637    } else {
1638        1
1639    }
1640}
1641
1642pub(crate) fn allocatable_cpu(instance: &InstanceTypeSpec) -> f64 {
1643    (instance.vcpu as f64 - SYSTEM_RESERVE_CPU).max(0.25)
1644}
1645
1646pub(crate) fn allocatable_memory_bytes(instance: &InstanceTypeSpec) -> u64 {
1647    instance
1648        .memory_bytes
1649        .saturating_sub(system_reserve_memory_bytes(instance.memory_bytes))
1650        .max(256 * MI)
1651}
1652
1653fn system_reserve_memory_bytes(memory_bytes: u64) -> u64 {
1654    if memory_bytes < 4 * GI {
1655        256 * MI
1656    } else if memory_bytes < 16 * GI {
1657        512 * MI
1658    } else {
1659        GI
1660    }
1661}
1662
1663// ---------------------------------------------------------------------------
1664// Tests
1665// ---------------------------------------------------------------------------
1666
1667#[cfg(test)]
1668mod tests {
1669    use super::*;
1670    use crate::BinaryTarget;
1671
1672    // -- Parsing tests --
1673
1674    #[test]
1675    fn test_parse_cpu_plain() {
1676        assert_eq!(parse_cpu("1").unwrap(), 1.0);
1677        assert_eq!(parse_cpu("0.5").unwrap(), 0.5);
1678        assert_eq!(parse_cpu("2.0").unwrap(), 2.0);
1679        assert_eq!(parse_cpu("16").unwrap(), 16.0);
1680    }
1681
1682    #[test]
1683    fn test_parse_cpu_millicore() {
1684        assert_eq!(parse_cpu("500m").unwrap(), 0.5);
1685        assert_eq!(parse_cpu("250m").unwrap(), 0.25);
1686        assert_eq!(parse_cpu("1000m").unwrap(), 1.0);
1687        assert_eq!(parse_cpu("100m").unwrap(), 0.1);
1688    }
1689
1690    #[test]
1691    fn test_parse_cpu_invalid() {
1692        assert!(parse_cpu("").is_err());
1693        assert!(parse_cpu("abc").is_err());
1694        assert!(parse_cpu("m").is_err());
1695    }
1696
1697    #[test]
1698    fn test_parse_memory_binary_suffixes() {
1699        assert_eq!(parse_memory_bytes("1Ki").unwrap(), 1024);
1700        assert_eq!(parse_memory_bytes("1Mi").unwrap(), 1024 * 1024);
1701        assert_eq!(parse_memory_bytes("1Gi").unwrap(), 1024 * 1024 * 1024);
1702        assert_eq!(parse_memory_bytes("4Gi").unwrap(), 4 * 1024 * 1024 * 1024);
1703        assert_eq!(parse_memory_bytes("512Mi").unwrap(), 512 * 1024 * 1024);
1704        assert_eq!(
1705            parse_memory_bytes("1Ti").unwrap(),
1706            1024u64 * 1024 * 1024 * 1024
1707        );
1708    }
1709
1710    #[test]
1711    fn test_parse_memory_decimal_suffixes() {
1712        assert_eq!(parse_memory_bytes("1k").unwrap(), 1000);
1713        assert_eq!(parse_memory_bytes("1M").unwrap(), 1_000_000);
1714        assert_eq!(parse_memory_bytes("1G").unwrap(), 1_000_000_000);
1715        assert_eq!(parse_memory_bytes("1T").unwrap(), 1_000_000_000_000);
1716    }
1717
1718    #[test]
1719    fn test_parse_memory_plain_bytes() {
1720        assert_eq!(parse_memory_bytes("1024").unwrap(), 1024);
1721        assert_eq!(parse_memory_bytes("0").unwrap(), 0);
1722    }
1723
1724    #[test]
1725    fn test_parse_memory_invalid() {
1726        assert!(parse_memory_bytes("").is_err());
1727        assert!(parse_memory_bytes("abc").is_err());
1728        assert!(parse_memory_bytes("Gi").is_err());
1729    }
1730
1731    #[test]
1732    fn test_parse_memory_fractional() {
1733        assert_eq!(parse_memory_bytes("0.5Gi").unwrap(), GI / 2);
1734        assert_eq!(parse_memory_bytes("1.5Gi").unwrap(), GI + GI / 2);
1735    }
1736
1737    // -- Catalog lookup tests --
1738
1739    #[test]
1740    fn test_catalog_has_entries_for_all_cloud_platforms() {
1741        assert!(!catalog_for_platform(Platform::Aws).is_empty());
1742        assert!(!catalog_for_platform(Platform::Gcp).is_empty());
1743        assert!(!catalog_for_platform(Platform::Azure).is_empty());
1744    }
1745
1746    #[test]
1747    fn test_catalog_no_entries_for_non_cloud_platforms() {
1748        assert!(catalog_for_platform(Platform::Local).is_empty());
1749        assert!(catalog_for_platform(Platform::Kubernetes).is_empty());
1750    }
1751
1752    #[test]
1753    fn test_find_known_instance_type() {
1754        let spec =
1755            find_instance_type(Platform::Aws, "m7g.2xlarge").expect("should find m7g.2xlarge");
1756        assert_eq!(spec.vcpu, 8);
1757        assert_eq!(spec.memory_bytes, 32 * GI);
1758        assert_eq!(spec.family, InstanceFamily::GeneralPurpose);
1759    }
1760
1761    #[test]
1762    fn test_find_aws_c8i_nested_virt_instance_type() {
1763        let spec = find_instance_type(Platform::Aws, "c8i.large").expect("should find c8i.large");
1764        assert_eq!(spec.vcpu, 2);
1765        assert_eq!(spec.memory_bytes, 4 * GI);
1766        assert_eq!(spec.family, InstanceFamily::ComputeOptimized);
1767        assert_eq!(spec.architecture, Architecture::X86_64);
1768        assert!(spec.is_nested_virt_capable());
1769    }
1770
1771    #[test]
1772    fn test_find_unknown_instance_type() {
1773        assert!(find_instance_type(Platform::Aws, "nonexistent.xlarge").is_none());
1774    }
1775
1776    #[test]
1777    fn test_find_wrong_platform() {
1778        assert!(find_instance_type(Platform::Gcp, "m7g.2xlarge").is_none());
1779    }
1780
1781    #[test]
1782    fn test_to_machine_profile() {
1783        let spec = find_instance_type(Platform::Aws, "m7g.2xlarge").unwrap();
1784        let profile = spec.to_machine_profile();
1785        assert_eq!(profile.cpu, "8.0");
1786        assert_eq!(profile.memory_bytes, 32 * GI);
1787        assert_eq!(profile.ephemeral_storage_bytes, 20 * GI);
1788        assert!(profile.gpu.is_none());
1789    }
1790
1791    #[test]
1792    fn test_to_machine_profile_with_gpu() {
1793        let spec = find_instance_type(Platform::Aws, "p4d.24xlarge").unwrap();
1794        let profile = spec.to_machine_profile();
1795        let gpu = profile.gpu.as_ref().expect("should have GPU");
1796        assert_eq!(gpu.gpu_type, "nvidia-a100");
1797        assert_eq!(gpu.count, 8);
1798    }
1799
1800    // -- Selection algorithm tests --
1801
1802    #[test]
1803    fn small_cpu_with_large_memory_falls_back_from_burstable() {
1804        let requirements = WorkloadRequirements {
1805            total_cpu_at_desired: 0.5,
1806            total_memory_bytes_at_desired: 8 * GI,
1807            total_cpu_at_max: 0.5,
1808            total_memory_bytes_at_max: 8 * GI,
1809            max_cpu_per_container: 0.5,
1810            max_memory_per_container: 8 * GI,
1811            max_ephemeral_storage_bytes: 0,
1812            gpu: None,
1813            architecture: Some(Architecture::X86_64),
1814            nested_virt: false,
1815        };
1816        let selected = select_instance_type(Platform::Gcp, &requirements).unwrap();
1817        let machine = find_instance_type(Platform::Gcp, selected.instance_type).unwrap();
1818        assert!(
1819            allocatable_memory_bytes(machine) as f64 >= 8.0 * GI as f64 * WORKLOAD_HEADROOM_FACTOR
1820        );
1821    }
1822
1823    #[test]
1824    fn test_select_burstable_for_small_workload() {
1825        let req = WorkloadRequirements {
1826            total_cpu_at_desired: 1.0,
1827            total_memory_bytes_at_desired: 2 * GI,
1828            total_cpu_at_max: 1.0,
1829            total_memory_bytes_at_max: 2 * GI,
1830            max_cpu_per_container: 0.5,
1831            max_memory_per_container: 1 * GI,
1832            max_ephemeral_storage_bytes: 10 * GI,
1833            gpu: None,
1834            architecture: None,
1835            nested_virt: false,
1836        };
1837        let sel = select_instance_type(Platform::Aws, &req).unwrap();
1838        let spec = find_instance_type(Platform::Aws, sel.instance_type).unwrap();
1839        assert_eq!(spec.family, InstanceFamily::Burstable);
1840    }
1841
1842    #[test]
1843    fn test_selects_smallest_burstable_machine_with_real_headroom() {
1844        let req = WorkloadRequirements {
1845            total_cpu_at_desired: 1.0,
1846            total_memory_bytes_at_desired: 2 * GI,
1847            total_cpu_at_max: 1.0,
1848            total_memory_bytes_at_max: 2 * GI,
1849            max_cpu_per_container: 1.0,
1850            max_memory_per_container: 2 * GI,
1851            max_ephemeral_storage_bytes: 10 * GI,
1852            gpu: None,
1853            architecture: None,
1854            nested_virt: false,
1855        };
1856
1857        let selection = select_instance_type(Platform::Aws, &req).unwrap();
1858
1859        assert_eq!(selection.instance_type, "t4g.medium");
1860        assert_eq!(selection.min_machines, 1);
1861        assert_eq!(selection.max_machines, 1);
1862    }
1863
1864    #[test]
1865    fn test_select_general_purpose_for_standard_workload() {
1866        // Standard workloads always get GeneralPurpose regardless of CPU:memory ratio
1867        let req = WorkloadRequirements {
1868            total_cpu_at_desired: 20.0,
1869            total_memory_bytes_at_desired: 80 * GI,
1870            total_cpu_at_max: 20.0,
1871            total_memory_bytes_at_max: 80 * GI,
1872            max_cpu_per_container: 2.0,
1873            max_memory_per_container: 8 * GI,
1874            max_ephemeral_storage_bytes: 10 * GI,
1875            gpu: None,
1876            architecture: None,
1877            nested_virt: false,
1878        };
1879        let sel = select_instance_type(Platform::Aws, &req).unwrap();
1880        let spec = find_instance_type(Platform::Aws, sel.instance_type).unwrap();
1881        assert_eq!(spec.family, InstanceFamily::GeneralPurpose);
1882    }
1883
1884    #[test]
1885    fn test_select_general_purpose_even_for_cpu_heavy() {
1886        // CPU-heavy workloads still get GeneralPurpose (no more ComputeOptimized auto-select)
1887        let req = WorkloadRequirements {
1888            total_cpu_at_desired: 20.0,
1889            total_memory_bytes_at_desired: 20 * GI,
1890            total_cpu_at_max: 20.0,
1891            total_memory_bytes_at_max: 20 * GI,
1892            max_cpu_per_container: 2.0,
1893            max_memory_per_container: 2 * GI,
1894            max_ephemeral_storage_bytes: 10 * GI,
1895            gpu: None,
1896            architecture: None,
1897            nested_virt: false,
1898        };
1899        let sel = select_instance_type(Platform::Aws, &req).unwrap();
1900        let spec = find_instance_type(Platform::Aws, sel.instance_type).unwrap();
1901        assert_eq!(spec.family, InstanceFamily::GeneralPurpose);
1902    }
1903
1904    #[test]
1905    fn test_select_storage_optimized_for_large_ephemeral() {
1906        let req = WorkloadRequirements {
1907            total_cpu_at_desired: 8.0,
1908            total_memory_bytes_at_desired: 32 * GI,
1909            total_cpu_at_max: 8.0,
1910            total_memory_bytes_at_max: 32 * GI,
1911            max_cpu_per_container: 2.0,
1912            max_memory_per_container: 8 * GI,
1913            max_ephemeral_storage_bytes: 500 * GI,
1914            gpu: None,
1915            architecture: Some(Architecture::X86_64),
1916            nested_virt: false,
1917        };
1918        let sel = select_instance_type(Platform::Aws, &req).unwrap();
1919        let spec = find_instance_type(Platform::Aws, sel.instance_type).unwrap();
1920        assert_eq!(spec.family, InstanceFamily::StorageOptimized);
1921    }
1922
1923    #[test]
1924    fn test_select_configurable_storage_above_fixed_local_catalog() {
1925        let req = WorkloadRequirements {
1926            total_cpu_at_desired: 8.0,
1927            total_memory_bytes_at_desired: 32 * GI,
1928            total_cpu_at_max: 8.0,
1929            total_memory_bytes_at_max: 32 * GI,
1930            max_cpu_per_container: 2.0,
1931            max_memory_per_container: 8 * GI,
1932            max_ephemeral_storage_bytes: 8_000 * GI,
1933            gpu: None,
1934            architecture: Some(Architecture::X86_64),
1935            nested_virt: false,
1936        };
1937        let sel = select_instance_type(Platform::Aws, &req).unwrap();
1938        let spec = find_instance_type(Platform::Aws, sel.instance_type).unwrap();
1939        assert_eq!(spec.family, InstanceFamily::GeneralPurpose);
1940        assert_eq!(sel.profile.ephemeral_storage_bytes, 8_000 * GI);
1941    }
1942
1943    #[test]
1944    fn test_configurable_storage_fallback_retains_standard_vcpu_cap() {
1945        let req = WorkloadRequirements {
1946            total_cpu_at_desired: 70.0,
1947            total_memory_bytes_at_desired: 140 * GI,
1948            total_cpu_at_max: 70.0,
1949            total_memory_bytes_at_max: 140 * GI,
1950            max_cpu_per_container: 2.0,
1951            max_memory_per_container: 4 * GI,
1952            max_ephemeral_storage_bytes: 8_000 * GI,
1953            gpu: None,
1954            architecture: Some(Architecture::X86_64),
1955            nested_virt: false,
1956        };
1957
1958        let sel = select_instance_type(Platform::Aws, &req).unwrap();
1959        let spec = find_instance_type(Platform::Aws, sel.instance_type).unwrap();
1960        assert_eq!(spec.family, InstanceFamily::GeneralPurpose);
1961        assert!(spec.vcpu <= MAX_STANDARD_VCPU);
1962        assert!(sel.max_machines > 1);
1963    }
1964
1965    #[test]
1966    fn test_nested_virtualization_storage_falls_back_to_configurable_disk() {
1967        for platform in [Platform::Aws, Platform::Gcp, Platform::Azure] {
1968            let req = WorkloadRequirements {
1969                total_cpu_at_desired: 2.0,
1970                total_memory_bytes_at_desired: 8 * GI,
1971                total_cpu_at_max: 2.0,
1972                total_memory_bytes_at_max: 8 * GI,
1973                max_cpu_per_container: 2.0,
1974                max_memory_per_container: 8 * GI,
1975                max_ephemeral_storage_bytes: 500 * GI,
1976                gpu: None,
1977                architecture: Some(Architecture::X86_64),
1978                nested_virt: true,
1979            };
1980
1981            let sel = select_instance_type(platform, &req)
1982                .unwrap_or_else(|error| panic!("{platform} should support fallback: {error}"));
1983            let spec = find_instance_type(platform, sel.instance_type).unwrap();
1984            assert_eq!(spec.family, InstanceFamily::GeneralPurpose);
1985            assert!(spec.is_nested_virt_capable());
1986            assert_eq!(sel.profile.ephemeral_storage_bytes, 500 * GI);
1987        }
1988    }
1989
1990    #[test]
1991    fn test_configurable_disk_rejects_capacity_above_provider_limit() {
1992        for (platform, requested) in [
1993            (Platform::Aws, 100 * TI),
1994            (Platform::Gcp, 100 * TI),
1995            (Platform::Azure, 8_000 * GI),
1996        ] {
1997            let req = WorkloadRequirements {
1998                total_cpu_at_desired: 2.0,
1999                total_memory_bytes_at_desired: 8 * GI,
2000                total_cpu_at_max: 2.0,
2001                total_memory_bytes_at_max: 8 * GI,
2002                max_cpu_per_container: 2.0,
2003                max_memory_per_container: 8 * GI,
2004                max_ephemeral_storage_bytes: requested,
2005                gpu: None,
2006                architecture: Some(Architecture::X86_64),
2007                nested_virt: true,
2008            };
2009
2010            assert!(select_instance_type(platform, &req).is_err());
2011        }
2012    }
2013
2014    #[test]
2015    fn test_configurable_disk_limits_account_for_controller_rounding() {
2016        for (platform, disk_limit_gib) in [
2017            (Platform::Aws, 64 * 1024),
2018            (Platform::Gcp, 64 * 1024),
2019            (Platform::Azure, 4_095),
2020        ] {
2021            let max = max_configurable_ephemeral_storage_bytes(platform).unwrap();
2022            let materialized_disk_gib = |requested_bytes: u64| {
2023                let requested_gib = requested_bytes.div_ceil(GI);
2024                (requested_gib * 5).div_ceil(4) + 12
2025            };
2026
2027            assert!(materialized_disk_gib(max) <= disk_limit_gib);
2028            assert!(materialized_disk_gib(max + 1) > disk_limit_gib);
2029        }
2030    }
2031
2032    #[test]
2033    fn test_gpu_local_storage_is_not_treated_as_resizable() {
2034        let spec = find_instance_type(Platform::Aws, "g5.xlarge").unwrap();
2035        assert_eq!(spec.family, InstanceFamily::GpuCompute);
2036        assert!(!spec.has_configurable_ephemeral_storage());
2037    }
2038
2039    #[test]
2040    fn test_select_gpu_instance() {
2041        let req = WorkloadRequirements {
2042            total_cpu_at_desired: 8.0,
2043            total_memory_bytes_at_desired: 32 * GI,
2044            total_cpu_at_max: 8.0,
2045            total_memory_bytes_at_max: 32 * GI,
2046            max_cpu_per_container: 4.0,
2047            max_memory_per_container: 16 * GI,
2048            max_ephemeral_storage_bytes: 10 * GI,
2049            gpu: Some(GpuSpec {
2050                gpu_type: "nvidia-a100".to_string(),
2051                count: 1,
2052            }),
2053            architecture: Some(Architecture::X86_64),
2054            nested_virt: false,
2055        };
2056        let sel = select_instance_type(Platform::Aws, &req).unwrap();
2057        let spec = find_instance_type(Platform::Aws, sel.instance_type).unwrap();
2058        assert_eq!(spec.family, InstanceFamily::GpuCompute);
2059        assert!(spec.gpu.is_some());
2060    }
2061
2062    #[test]
2063    fn test_select_uses_each_cloud_image_target_architecture() {
2064        let req = WorkloadRequirements {
2065            total_cpu_at_desired: 4.0,
2066            total_memory_bytes_at_desired: 16 * GI,
2067            total_cpu_at_max: 4.0,
2068            total_memory_bytes_at_max: 16 * GI,
2069            max_cpu_per_container: 1.0,
2070            max_memory_per_container: 4 * GI,
2071            max_ephemeral_storage_bytes: 10 * GI,
2072            gpu: None,
2073            architecture: None,
2074            nested_virt: false,
2075        };
2076        for platform in [Platform::Aws, Platform::Gcp, Platform::Azure] {
2077            let sel = select_instance_type(platform, &req)
2078                .unwrap_or_else(|error| panic!("selection failed for {platform}: {error}"));
2079            let spec = find_instance_type(platform, sel.instance_type)
2080                .expect("selected machine should exist in the catalog");
2081            assert_eq!(
2082                Some(spec.architecture),
2083                default_architecture(platform),
2084                "machine architecture must match the image target for {platform}"
2085            );
2086        }
2087    }
2088
2089    #[test]
2090    fn test_machine_count_reasonable() {
2091        // Single container: 1 CPU, 2Gi, maxReplicas=20
2092        let req = WorkloadRequirements {
2093            total_cpu_at_desired: 20.0,
2094            total_memory_bytes_at_desired: 40 * GI,
2095            total_cpu_at_max: 20.0,
2096            total_memory_bytes_at_max: 40 * GI,
2097            max_cpu_per_container: 1.0,
2098            max_memory_per_container: 2 * GI,
2099            max_ephemeral_storage_bytes: 10 * GI,
2100            gpu: None,
2101            architecture: None,
2102            nested_virt: false,
2103        };
2104        let sel = select_instance_type(Platform::Aws, &req).unwrap();
2105        assert!(sel.min_machines >= 1);
2106        assert!(sel.max_machines <= MAX_MACHINES_PER_CLUSTER);
2107        assert!(sel.max_machines >= sel.min_machines);
2108    }
2109
2110    #[test]
2111    fn test_instance_size_capped_at_8_vcpu() {
2112        // Many small replicas prefer more machines over larger instances.
2113        let req = WorkloadRequirements {
2114            total_cpu_at_desired: 70.0,
2115            total_memory_bytes_at_desired: 140 * GI,
2116            total_cpu_at_max: 70.0,
2117            total_memory_bytes_at_max: 140 * GI,
2118            max_cpu_per_container: 2.0,
2119            max_memory_per_container: 4 * GI,
2120            max_ephemeral_storage_bytes: 10 * GI,
2121            gpu: None,
2122            architecture: None,
2123            nested_virt: false,
2124        };
2125        let sel = select_instance_type(Platform::Gcp, &req).unwrap();
2126        let spec = find_instance_type(Platform::Gcp, sel.instance_type).unwrap();
2127        assert!(
2128            spec.vcpu <= MAX_STANDARD_VCPU,
2129            "selected {} with {} vCPUs, expected <= {}",
2130            spec.name,
2131            spec.vcpu,
2132            MAX_STANDARD_VCPU
2133        );
2134        assert_eq!(spec.family, InstanceFamily::GeneralPurpose);
2135        // Should scale horizontally instead
2136        assert!(sel.max_machines > 1);
2137    }
2138
2139    #[test]
2140    fn test_larger_autoscaled_workload_gets_reasonable_instance() {
2141        // Simulates a larger autoscaled workload: 4 containers, each 2 CPU / 4 GiB
2142        // maxReplicas: 10, 10, 10, 5
2143        let req = WorkloadRequirements {
2144            total_cpu_at_desired: 70.0,
2145            total_memory_bytes_at_desired: 140 * GI,
2146            total_cpu_at_max: 70.0,              // 2*10 + 2*10 + 2*10 + 2*5
2147            total_memory_bytes_at_max: 140 * GI, // 4*10 + 4*10 + 4*10 + 4*5
2148            max_cpu_per_container: 2.0,
2149            max_memory_per_container: 4 * GI,
2150            max_ephemeral_storage_bytes: 20 * GI,
2151            gpu: None,
2152            architecture: None,
2153            nested_virt: false,
2154        };
2155        let sel = select_instance_type(Platform::Gcp, &req).unwrap();
2156        // Should pick n2-standard-8 (8 vCPU, 32 GiB) — NOT c3-standard-44
2157        assert_eq!(sel.instance_type, "n2-standard-8");
2158        assert!(sel.max_machines >= 2);
2159    }
2160
2161    /// When `nested_virt` is set on the workload, the selector must
2162    /// restrict to nested-virt-capable families. On AWS that means an m8i
2163    /// (or other 8th-gen Intel) entry, never a Graviton (`*7g`, `t4g`) or
2164    /// burstable. Without this filter the launch template gets created
2165    /// with `CpuOptions.NestedVirtualization=enabled` paired with an
2166    /// instance type AWS rejects at RunInstances.
2167    #[test]
2168    fn test_select_aws_picks_m8i_when_nested_virt_required() {
2169        let req = WorkloadRequirements {
2170            total_cpu_at_desired: 4.0,
2171            total_memory_bytes_at_desired: 8 * GI,
2172            total_cpu_at_max: 4.0,
2173            total_memory_bytes_at_max: 8 * GI,
2174            max_cpu_per_container: 4.0,
2175            max_memory_per_container: 8 * GI,
2176            max_ephemeral_storage_bytes: 10 * GI,
2177            gpu: None,
2178            architecture: Some(Architecture::X86_64),
2179            nested_virt: true,
2180        };
2181        let sel = select_instance_type(Platform::Aws, &req).unwrap();
2182        assert!(
2183            sel.instance_type.starts_with("m8i.")
2184                || sel.instance_type.starts_with("c8i.")
2185                || sel.instance_type.starts_with("r8i."),
2186            "expected an m8i/c8i/r8i instance, got {}",
2187            sel.instance_type
2188        );
2189        let spec = find_instance_type(Platform::Aws, sel.instance_type).unwrap();
2190        assert!(spec.is_nested_virt_capable());
2191    }
2192
2193    #[test]
2194    fn test_select_gcp_picks_n2_when_nested_virt_required() {
2195        let req = WorkloadRequirements {
2196            total_cpu_at_desired: 4.0,
2197            total_memory_bytes_at_desired: 8 * GI,
2198            total_cpu_at_max: 4.0,
2199            total_memory_bytes_at_max: 8 * GI,
2200            max_cpu_per_container: 4.0,
2201            max_memory_per_container: 8 * GI,
2202            max_ephemeral_storage_bytes: 0,
2203            architecture: Some(Architecture::X86_64),
2204            gpu: None,
2205            nested_virt: true,
2206        };
2207
2208        let selection = select_instance_type(Platform::Gcp, &req).unwrap();
2209        assert_eq!(selection.instance_type, "n2-standard-8");
2210        assert!(find_instance_type(Platform::Gcp, selection.instance_type)
2211            .unwrap()
2212            .is_nested_virt_capable());
2213    }
2214
2215    #[test]
2216    fn test_select_azure_picks_dsv5_when_nested_virt_required() {
2217        let req = WorkloadRequirements {
2218            total_cpu_at_desired: 4.0,
2219            total_memory_bytes_at_desired: 8 * GI,
2220            total_cpu_at_max: 4.0,
2221            total_memory_bytes_at_max: 8 * GI,
2222            max_cpu_per_container: 4.0,
2223            max_memory_per_container: 8 * GI,
2224            max_ephemeral_storage_bytes: 0,
2225            architecture: Some(Architecture::X86_64),
2226            gpu: None,
2227            nested_virt: true,
2228        };
2229
2230        let selection = select_instance_type(Platform::Azure, &req).unwrap();
2231        assert_eq!(selection.instance_type, "Standard_D8s_v5");
2232        assert!(find_instance_type(Platform::Azure, selection.instance_type)
2233            .unwrap()
2234            .is_nested_virt_capable());
2235    }
2236
2237    #[test]
2238    fn test_select_aws_defaults_to_image_target_architecture() {
2239        let req = WorkloadRequirements {
2240            total_cpu_at_desired: 4.0,
2241            total_memory_bytes_at_desired: 8 * GI,
2242            total_cpu_at_max: 4.0,
2243            total_memory_bytes_at_max: 8 * GI,
2244            max_cpu_per_container: 4.0,
2245            max_memory_per_container: 8 * GI,
2246            max_ephemeral_storage_bytes: 10 * GI,
2247            gpu: None,
2248            architecture: None,
2249            nested_virt: false,
2250        };
2251        let sel = select_instance_type(Platform::Aws, &req).unwrap();
2252        let spec = find_instance_type(Platform::Aws, sel.instance_type).unwrap();
2253        assert_eq!(spec.architecture, Architecture::Arm64);
2254    }
2255
2256    #[test]
2257    fn test_cloud_defaults_match_image_target_architectures() {
2258        for platform in [Platform::Aws, Platform::Gcp, Platform::Azure] {
2259            let target = BinaryTarget::defaults_for_platform(platform)
2260                .into_iter()
2261                .next()
2262                .expect("managed cloud should have a default image target");
2263            let image_architecture = match target.oci_arch() {
2264                "arm64" => Architecture::Arm64,
2265                "amd64" => Architecture::X86_64,
2266                architecture => {
2267                    panic!("unsupported managed-cloud image architecture {architecture}")
2268                }
2269            };
2270
2271            assert_eq!(default_architecture(platform), Some(image_architecture));
2272        }
2273    }
2274
2275    /// ARM remains available when the workload or capacity profile declares it.
2276    #[test]
2277    fn test_select_aws_uses_graviton_for_explicit_arm64() {
2278        let req = WorkloadRequirements {
2279            total_cpu_at_desired: 4.0,
2280            total_memory_bytes_at_desired: 8 * GI,
2281            total_cpu_at_max: 4.0,
2282            total_memory_bytes_at_max: 8 * GI,
2283            max_cpu_per_container: 4.0,
2284            max_memory_per_container: 8 * GI,
2285            max_ephemeral_storage_bytes: 10 * GI,
2286            gpu: None,
2287            architecture: Some(Architecture::Arm64),
2288            nested_virt: false,
2289        };
2290        let sel = select_instance_type(Platform::Aws, &req).unwrap();
2291        let spec = find_instance_type(Platform::Aws, sel.instance_type).unwrap();
2292        assert_eq!(spec.architecture, Architecture::Arm64);
2293    }
2294
2295    #[test]
2296    fn test_select_rejects_explicit_architecture_missing_from_cloud_catalog() {
2297        let req = WorkloadRequirements {
2298            total_cpu_at_desired: 1.0,
2299            total_memory_bytes_at_desired: 2 * GI,
2300            total_cpu_at_max: 1.0,
2301            total_memory_bytes_at_max: 2 * GI,
2302            max_cpu_per_container: 1.0,
2303            max_memory_per_container: 2 * GI,
2304            max_ephemeral_storage_bytes: 10 * GI,
2305            gpu: None,
2306            architecture: Some(Architecture::Arm64),
2307            nested_virt: false,
2308        };
2309
2310        let error = select_instance_type(Platform::Gcp, &req)
2311            .expect_err("GCP catalog has no ARM64 machine");
2312
2313        assert!(error.contains("architecture Arm64 is unavailable"));
2314    }
2315
2316    #[test]
2317    fn test_profile_has_required_fields() {
2318        let req = WorkloadRequirements {
2319            total_cpu_at_desired: 4.0,
2320            total_memory_bytes_at_desired: 16 * GI,
2321            total_cpu_at_max: 4.0,
2322            total_memory_bytes_at_max: 16 * GI,
2323            max_cpu_per_container: 1.0,
2324            max_memory_per_container: 4 * GI,
2325            max_ephemeral_storage_bytes: 10 * GI,
2326            gpu: None,
2327            architecture: None,
2328            nested_virt: false,
2329        };
2330        let sel = select_instance_type(Platform::Aws, &req).unwrap();
2331        assert!(!sel.profile.cpu.is_empty());
2332        assert!(sel.profile.memory_bytes > 0);
2333        assert!(sel.profile.ephemeral_storage_bytes > 0);
2334    }
2335
2336    #[test]
2337    fn test_error_for_unsupported_gpu_type() {
2338        let req = WorkloadRequirements {
2339            total_cpu_at_desired: 8.0,
2340            total_memory_bytes_at_desired: 32 * GI,
2341            total_cpu_at_max: 8.0,
2342            total_memory_bytes_at_max: 32 * GI,
2343            max_cpu_per_container: 4.0,
2344            max_memory_per_container: 16 * GI,
2345            max_ephemeral_storage_bytes: 10 * GI,
2346            gpu: Some(GpuSpec {
2347                gpu_type: "amd-mi300".to_string(),
2348                count: 1,
2349            }),
2350            architecture: None,
2351            nested_virt: false,
2352        };
2353        let result = select_instance_type(Platform::Aws, &req);
2354        assert!(result.is_err());
2355    }
2356
2357    #[test]
2358    fn test_catalog_instance_types_sorted_by_vcpu_within_family() {
2359        // Verify that within each (platform, family) group, vcpu is non-decreasing.
2360        // This ensures our "min_by_key(vcpu)" logic works correctly.
2361        for platform in [Platform::Aws, Platform::Gcp, Platform::Azure] {
2362            let entries = catalog_for_platform(platform);
2363            let mut by_family: std::collections::HashMap<_, Vec<_>> =
2364                std::collections::HashMap::new();
2365            for entry in entries {
2366                by_family
2367                    .entry(format!("{:?}", entry.family))
2368                    .or_default()
2369                    .push(entry);
2370            }
2371            for (family, instances) in &by_family {
2372                for window in instances.windows(2) {
2373                    assert!(
2374                        window[0].vcpu <= window[1].vcpu,
2375                        "catalog not sorted by vcpu for {platform}/{family}: {} ({}) > {} ({})",
2376                        window[0].name,
2377                        window[0].vcpu,
2378                        window[1].name,
2379                        window[1].vcpu
2380                    );
2381                }
2382            }
2383        }
2384    }
2385}