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