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 (Dv5-series, up to 16 vCPU)
1039    InstanceTypeSpec {
1040        name: "Standard_D2s_v5",
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_D4s_v5",
1051        platform: Platform::Azure,
1052        family: InstanceFamily::GeneralPurpose,
1053        architecture: Architecture::X86_64,
1054        vcpu: 4,
1055        memory_bytes: 16 * GI,
1056        ephemeral_storage_bytes: 20 * GI,
1057        gpu: None,
1058    },
1059    InstanceTypeSpec {
1060        name: "Standard_D8s_v5",
1061        platform: Platform::Azure,
1062        family: InstanceFamily::GeneralPurpose,
1063        architecture: Architecture::X86_64,
1064        vcpu: 8,
1065        memory_bytes: 32 * GI,
1066        ephemeral_storage_bytes: 20 * GI,
1067        gpu: None,
1068    },
1069    InstanceTypeSpec {
1070        name: "Standard_D16s_v5",
1071        platform: Platform::Azure,
1072        family: InstanceFamily::GeneralPurpose,
1073        architecture: Architecture::X86_64,
1074        vcpu: 16,
1075        memory_bytes: 64 * GI,
1076        ephemeral_storage_bytes: 20 * GI,
1077        gpu: None,
1078    },
1079    // Compute Optimized (Fv2-series, up to 16 vCPU)
1080    InstanceTypeSpec {
1081        name: "Standard_F2s_v2",
1082        platform: Platform::Azure,
1083        family: InstanceFamily::ComputeOptimized,
1084        architecture: Architecture::X86_64,
1085        vcpu: 2,
1086        memory_bytes: 4 * GI,
1087        ephemeral_storage_bytes: 20 * GI,
1088        gpu: None,
1089    },
1090    InstanceTypeSpec {
1091        name: "Standard_F4s_v2",
1092        platform: Platform::Azure,
1093        family: InstanceFamily::ComputeOptimized,
1094        architecture: Architecture::X86_64,
1095        vcpu: 4,
1096        memory_bytes: 8 * GI,
1097        ephemeral_storage_bytes: 20 * GI,
1098        gpu: None,
1099    },
1100    InstanceTypeSpec {
1101        name: "Standard_F8s_v2",
1102        platform: Platform::Azure,
1103        family: InstanceFamily::ComputeOptimized,
1104        architecture: Architecture::X86_64,
1105        vcpu: 8,
1106        memory_bytes: 16 * GI,
1107        ephemeral_storage_bytes: 20 * GI,
1108        gpu: None,
1109    },
1110    InstanceTypeSpec {
1111        name: "Standard_F16s_v2",
1112        platform: Platform::Azure,
1113        family: InstanceFamily::ComputeOptimized,
1114        architecture: Architecture::X86_64,
1115        vcpu: 16,
1116        memory_bytes: 32 * GI,
1117        ephemeral_storage_bytes: 20 * GI,
1118        gpu: None,
1119    },
1120    // Memory Optimized (Ev5-series, up to 16 vCPU)
1121    InstanceTypeSpec {
1122        name: "Standard_E2s_v5",
1123        platform: Platform::Azure,
1124        family: InstanceFamily::MemoryOptimized,
1125        architecture: Architecture::X86_64,
1126        vcpu: 2,
1127        memory_bytes: 16 * GI,
1128        ephemeral_storage_bytes: 20 * GI,
1129        gpu: None,
1130    },
1131    InstanceTypeSpec {
1132        name: "Standard_E4s_v5",
1133        platform: Platform::Azure,
1134        family: InstanceFamily::MemoryOptimized,
1135        architecture: Architecture::X86_64,
1136        vcpu: 4,
1137        memory_bytes: 32 * GI,
1138        ephemeral_storage_bytes: 20 * GI,
1139        gpu: None,
1140    },
1141    InstanceTypeSpec {
1142        name: "Standard_E8s_v5",
1143        platform: Platform::Azure,
1144        family: InstanceFamily::MemoryOptimized,
1145        architecture: Architecture::X86_64,
1146        vcpu: 8,
1147        memory_bytes: 64 * GI,
1148        ephemeral_storage_bytes: 20 * GI,
1149        gpu: None,
1150    },
1151    InstanceTypeSpec {
1152        name: "Standard_E16s_v5",
1153        platform: Platform::Azure,
1154        family: InstanceFamily::MemoryOptimized,
1155        architecture: Architecture::X86_64,
1156        vcpu: 16,
1157        memory_bytes: 128 * GI,
1158        ephemeral_storage_bytes: 20 * GI,
1159        gpu: None,
1160    },
1161    // Storage Optimized (Lsv3-series with NVMe)
1162    InstanceTypeSpec {
1163        name: "Standard_L8s_v3",
1164        platform: Platform::Azure,
1165        family: InstanceFamily::StorageOptimized,
1166        architecture: Architecture::X86_64,
1167        vcpu: 8,
1168        memory_bytes: 64 * GI,
1169        ephemeral_storage_bytes: 1788 * GI,
1170        gpu: None,
1171    },
1172    InstanceTypeSpec {
1173        name: "Standard_L16s_v3",
1174        platform: Platform::Azure,
1175        family: InstanceFamily::StorageOptimized,
1176        architecture: Architecture::X86_64,
1177        vcpu: 16,
1178        memory_bytes: 128 * GI,
1179        ephemeral_storage_bytes: 3576 * GI,
1180        gpu: None,
1181    },
1182    InstanceTypeSpec {
1183        name: "Standard_L32s_v3",
1184        platform: Platform::Azure,
1185        family: InstanceFamily::StorageOptimized,
1186        architecture: Architecture::X86_64,
1187        vcpu: 32,
1188        memory_bytes: 256 * GI,
1189        ephemeral_storage_bytes: 7154 * GI,
1190        gpu: None,
1191    },
1192    // GPU — NVIDIA T4 (NCasT4_v3-series)
1193    InstanceTypeSpec {
1194        name: "Standard_NC4as_T4_v3",
1195        platform: Platform::Azure,
1196        family: InstanceFamily::GpuCompute,
1197        architecture: Architecture::X86_64,
1198        vcpu: 4,
1199        memory_bytes: 28 * GI,
1200        ephemeral_storage_bytes: 176 * GI,
1201        gpu: Some(CatalogGpu {
1202            gpu_type: "nvidia-t4",
1203            count: 1,
1204        }),
1205    },
1206    // GPU — NVIDIA A100 (NC A100 v4-series)
1207    InstanceTypeSpec {
1208        name: "Standard_NC24ads_A100_v4",
1209        platform: Platform::Azure,
1210        family: InstanceFamily::GpuCompute,
1211        architecture: Architecture::X86_64,
1212        vcpu: 24,
1213        memory_bytes: 220 * GI,
1214        ephemeral_storage_bytes: 958 * GI,
1215        gpu: Some(CatalogGpu {
1216            gpu_type: "nvidia-a100",
1217            count: 1,
1218        }),
1219    },
1220    InstanceTypeSpec {
1221        name: "Standard_NC96ads_A100_v4",
1222        platform: Platform::Azure,
1223        family: InstanceFamily::GpuCompute,
1224        architecture: Architecture::X86_64,
1225        vcpu: 96,
1226        memory_bytes: 880 * GI,
1227        ephemeral_storage_bytes: 3916 * GI,
1228        gpu: Some(CatalogGpu {
1229            gpu_type: "nvidia-a100",
1230            count: 4,
1231        }),
1232    },
1233    // GPU — NVIDIA H100 (ND H100 v5-series)
1234    InstanceTypeSpec {
1235        name: "Standard_ND96isr_H100_v5",
1236        platform: Platform::Azure,
1237        family: InstanceFamily::GpuCompute,
1238        architecture: Architecture::X86_64,
1239        vcpu: 96,
1240        memory_bytes: 1900 * GI,
1241        ephemeral_storage_bytes: 1000 * GI,
1242        gpu: Some(CatalogGpu {
1243            gpu_type: "nvidia-h100",
1244            count: 8,
1245        }),
1246    },
1247];
1248
1249// ---------------------------------------------------------------------------
1250// Catalog lookup
1251// ---------------------------------------------------------------------------
1252
1253/// Get all instance types for a given platform.
1254pub fn catalog_for_platform(platform: Platform) -> Vec<&'static InstanceTypeSpec> {
1255    CATALOG
1256        .iter()
1257        .filter(|spec| spec.platform == platform)
1258        .collect()
1259}
1260
1261/// Find a specific instance type by name and platform.
1262pub fn find_instance_type(platform: Platform, name: &str) -> Option<&'static InstanceTypeSpec> {
1263    CATALOG
1264        .iter()
1265        .find(|spec| spec.platform == platform && spec.name == name)
1266}
1267
1268/// Whether a capacity group may move from AWS machine `old` to `new` without setup: both are
1269/// catalog machines of one CPU architecture, so the stack's images still run on the new one.
1270pub fn is_same_architecture_aws_machine(old: &str, new: &str) -> bool {
1271    match (
1272        find_instance_type(Platform::Aws, old),
1273        find_instance_type(Platform::Aws, new),
1274    ) {
1275        (Some(old), Some(new)) => old.architecture == new.architecture,
1276        _ => false,
1277    }
1278}
1279
1280// ---------------------------------------------------------------------------
1281// Instance type selection
1282// ---------------------------------------------------------------------------
1283
1284/// Aggregated resource requirements from all containers in a capacity group.
1285#[derive(Debug, Clone)]
1286pub struct WorkloadRequirements {
1287    /// Total CPU needed at desired scale (sum of desired CPU * desired_replicas per container)
1288    pub total_cpu_at_desired: f64,
1289    /// Total memory needed at desired scale (sum of desired memory * desired_replicas per container)
1290    pub total_memory_bytes_at_desired: u64,
1291    /// Total CPU needed at maximum scale (sum of desired CPU * max_replicas per container)
1292    pub total_cpu_at_max: f64,
1293    /// Total memory needed at maximum scale (sum of desired memory * max_replicas per container)
1294    pub total_memory_bytes_at_max: u64,
1295    /// Largest CPU request among all individual containers (single replica)
1296    pub max_cpu_per_container: f64,
1297    /// Largest memory request among all individual containers (single replica)
1298    pub max_memory_per_container: u64,
1299    /// Maximum ephemeral storage any single container requires
1300    pub max_ephemeral_storage_bytes: u64,
1301    /// GPU requirement (if any container needs GPU)
1302    pub gpu: Option<GpuSpec>,
1303    /// Required CPU architecture, when source explicitly constrains it.
1304    pub architecture: Option<Architecture>,
1305    /// If true, only instance types that expose nested virtualization (VT-x/EPT)
1306    /// to guest VMs are eligible. Required by workloads that run QEMU/KVM
1307    /// inside a container.
1308    pub nested_virt: bool,
1309}
1310
1311/// Result of instance type selection.
1312#[derive(Debug, Clone)]
1313pub struct InstanceSelection {
1314    /// Selected instance type name (e.g., "m7g.2xlarge")
1315    pub instance_type: &'static str,
1316    /// Machine profile derived from the instance type
1317    pub profile: MachineProfile,
1318    /// Recommended minimum number of machines
1319    pub min_machines: u32,
1320    /// Recommended maximum number of machines
1321    pub max_machines: u32,
1322}
1323
1324/// Ephemeral storage threshold above which storage-optimized instances are selected.
1325const STORAGE_OPTIMIZED_THRESHOLD: u64 = 200 * GI;
1326
1327/// Maximum number of machines per cluster.
1328const MAX_MACHINES_PER_CLUSTER: u32 = 10;
1329
1330/// Hard cap on vCPUs for non-GPU/non-storage workloads. Equivalent to AWS 2xlarge.
1331/// Beyond this, horizontal scaling is always preferred over bigger machines.
1332const MAX_STANDARD_VCPU: u32 = 8;
1333
1334/// Runtime CPU reserved for system processes on each managed container machine.
1335const SYSTEM_RESERVE_CPU: f64 = 0.5;
1336
1337/// Runtime planning headroom for total desired/max workload.
1338const WORKLOAD_HEADROOM_FACTOR: f64 = 1.15;
1339
1340/// Select the best instance type for a workload on a given platform.
1341///
1342/// The algorithm:
1343/// 1. GPU workloads: Match by GPU type, find smallest instance with enough GPUs.
1344/// 2. Storage-heavy workloads (>200Gi ephemeral): Use storage-optimized instances.
1345/// 3. All other workloads: Size the machine to fit a small HA-friendly baseline,
1346///    capped at 8 vCPUs. Use GeneralPurpose family for broad availability and
1347///    reasonable cost. Scale horizontally for more capacity.
1348///
1349/// Returns an error if no suitable instance type is found.
1350pub fn select_instance_type(
1351    platform: Platform,
1352    requirements: &WorkloadRequirements,
1353) -> Result<InstanceSelection, String> {
1354    let architecture = requirements
1355        .architecture
1356        .or_else(|| default_architecture(platform))
1357        .ok_or_else(|| format!("platform {platform} has no default compute architecture"))?;
1358
1359    // Determine which family to use. Nested virt isn't available on
1360    // burstable hardware on any cloud, so a workload that classifies as
1361    // Burstable but needs nested virt must be upgraded to GeneralPurpose
1362    // (the family that actually has nested-virt-capable entries).
1363    let raw_family = select_family(requirements);
1364    let family = if requirements.nested_virt && raw_family == InstanceFamily::Burstable {
1365        InstanceFamily::GeneralPurpose
1366    } else {
1367        raw_family
1368    };
1369
1370    let candidates: Vec<&InstanceTypeSpec> = CATALOG
1371        .iter()
1372        .filter(|spec| spec.platform == platform && spec.family == family)
1373        .filter(|spec| {
1374            if requirements.nested_virt {
1375                spec.is_nested_virt_capable()
1376            } else {
1377                platform != Platform::Aws || !spec.is_nested_virt_capable()
1378            }
1379        })
1380        .filter(|spec| spec.architecture == architecture)
1381        .collect();
1382
1383    // A storage-heavy workload may have no fixed-local candidate for the
1384    // requested architecture/nested-virtualization contract. That is exactly
1385    // when we must consider a provider-backed disk on a general-purpose VM;
1386    // rejecting here made the fallback below unreachable.
1387    if candidates.is_empty() && family != InstanceFamily::StorageOptimized {
1388        let family_has_other_architecture = CATALOG.iter().any(|spec| {
1389            spec.platform == platform
1390                && spec.family == family
1391                && if requirements.nested_virt {
1392                    spec.is_nested_virt_capable()
1393                } else {
1394                    platform != Platform::Aws || !spec.is_nested_virt_capable()
1395                }
1396        });
1397        if family_has_other_architecture {
1398            return Err(format!(
1399                "architecture {architecture:?} is unavailable for this workload on platform {platform}"
1400            ));
1401        }
1402        return Err(if requirements.nested_virt {
1403            format!(
1404                "no nested-virt-capable {family:?} instance types in catalog for platform {platform}"
1405            )
1406        } else {
1407            format!("no {family:?} instance types in catalog for platform {platform}")
1408        });
1409    }
1410
1411    // For GPU workloads, filter by GPU type
1412    let candidates = if let Some(ref gpu) = requirements.gpu {
1413        let filtered: Vec<&InstanceTypeSpec> = candidates
1414            .into_iter()
1415            .filter(|spec| {
1416                spec.gpu.as_ref().map_or(false, |g| {
1417                    g.gpu_type == gpu.gpu_type && g.count >= gpu.count
1418                })
1419            })
1420            .collect();
1421        if filtered.is_empty() {
1422            return Err(format!(
1423                "no instance type for GPU type '{}' x{} on platform {platform}",
1424                gpu.gpu_type, gpu.count
1425            ));
1426        }
1427        filtered
1428    } else {
1429        candidates
1430    };
1431
1432    // For storage workloads, filter by ephemeral storage capacity
1433    let candidates = if family == InstanceFamily::StorageOptimized {
1434        let filtered: Vec<&InstanceTypeSpec> = candidates
1435            .into_iter()
1436            .filter(|spec| spec.ephemeral_storage_bytes >= requirements.max_ephemeral_storage_bytes)
1437            .collect();
1438        if filtered.is_empty() {
1439            // Fixed-local NVMe is preferred for large requests, but provider
1440            // disks on ordinary cloud machines remain configurable. Fall back
1441            // when no fixed-local catalog entry can satisfy the request.
1442            CATALOG
1443                .iter()
1444                .filter(|spec| {
1445                    spec.platform == platform
1446                        && spec.family == InstanceFamily::GeneralPurpose
1447                        && spec.has_configurable_ephemeral_storage()
1448                        && max_configurable_ephemeral_storage_bytes(platform)
1449                            .is_some_and(|max| requirements.max_ephemeral_storage_bytes <= max)
1450                })
1451                .filter(|spec| {
1452                    if requirements.nested_virt {
1453                        spec.is_nested_virt_capable()
1454                    } else {
1455                        platform != Platform::Aws || !spec.is_nested_virt_capable()
1456                    }
1457                })
1458                .filter(|spec| spec.architecture == architecture)
1459                .collect()
1460        } else {
1461            filtered
1462        }
1463    } else {
1464        candidates
1465    };
1466
1467    if candidates.is_empty() {
1468        return Err(format!(
1469            "architecture {architecture:?} is unavailable for this workload on platform {platform}"
1470        ));
1471    }
1472
1473    // Apply the policy for the candidates we will actually select from. A
1474    // storage-heavy request can fall back from fixed-local storage machines to
1475    // general-purpose machines with provider-backed disks; those machines must
1476    // retain the normal horizontal-scaling cap.
1477    let effective_family = candidates[0].family;
1478    let vcpu_cap = if effective_family == InstanceFamily::GpuCompute
1479        || effective_family == InstanceFamily::StorageOptimized
1480    {
1481        u32::MAX
1482    } else {
1483        MAX_STANDARD_VCPU
1484    };
1485
1486    let desired_target_machines = desired_target_machines(requirements);
1487    let target_cpu = requirements
1488        .max_cpu_per_container
1489        .max(requirements.total_cpu_at_desired / desired_target_machines as f64)
1490        * WORKLOAD_HEADROOM_FACTOR;
1491    let target_memory = (requirements.max_memory_per_container as f64)
1492        .max(requirements.total_memory_bytes_at_desired as f64 / desired_target_machines as f64)
1493        * WORKLOAD_HEADROOM_FACTOR;
1494
1495    // Find the smallest instance whose allocatable capacity meets the workload
1496    // target after host reserve and workload headroom. Machine count already
1497    // accounts for multiple replicas; requiring space for an arbitrary second
1498    // copy here would size the same demand twice.
1499    let selected = candidates
1500        .iter()
1501        .filter(|spec| {
1502            spec.vcpu <= vcpu_cap
1503                && allocatable_cpu(spec) >= target_cpu
1504                && allocatable_memory_bytes(spec) as f64 >= target_memory
1505        })
1506        .min_by_key(|spec| spec.vcpu)
1507        .or_else(|| {
1508            // If nothing fits within the cap, pick the largest instance under the cap
1509            candidates
1510                .iter()
1511                .filter(|spec| spec.vcpu <= vcpu_cap)
1512                .max_by_key(|spec| spec.vcpu)
1513        })
1514        .or_else(|| {
1515            // Last resort: pick the smallest available instance (for GPU/storage)
1516            candidates.iter().min_by_key(|spec| spec.vcpu)
1517        })
1518        .ok_or_else(|| format!("no instance types available for platform {platform}"))?;
1519
1520    // Calculate machine counts
1521    let max_machines = compute_max_machines(requirements, selected);
1522    let min_machines = compute_min_machines(requirements, selected, max_machines);
1523
1524    Ok(InstanceSelection {
1525        instance_type: selected.name,
1526        profile: selected.to_machine_profile_for_storage(requirements.max_ephemeral_storage_bytes),
1527        min_machines,
1528        max_machines,
1529    })
1530}
1531
1532/// Select instance family based on workload characteristics.
1533///
1534/// Uses GeneralPurpose for all standard workloads — widely available across
1535/// regions and cost-effective. Only specialized workloads (GPU, large ephemeral
1536/// storage) get specialized families. Very small workloads get burstable.
1537pub fn select_family(requirements: &WorkloadRequirements) -> InstanceFamily {
1538    // GPU workloads always get GPU instances
1539    if requirements.gpu.is_some() {
1540        return InstanceFamily::GpuCompute;
1541    }
1542
1543    // Large ephemeral storage needs NVMe (storage-optimized)
1544    if requirements.max_ephemeral_storage_bytes > STORAGE_OPTIMIZED_THRESHOLD {
1545        return InstanceFamily::StorageOptimized;
1546    }
1547
1548    // Very small workloads use burstable instances
1549    if requirements.total_cpu_at_max < 2.0 {
1550        return InstanceFamily::Burstable;
1551    }
1552
1553    // All other workloads use GeneralPurpose — available everywhere, good pricing
1554    InstanceFamily::GeneralPurpose
1555}
1556
1557/// Calculate maximum machines needed to fit the workload with headroom.
1558fn compute_max_machines(requirements: &WorkloadRequirements, instance: &InstanceTypeSpec) -> u32 {
1559    let cpu_with_headroom = requirements.total_cpu_at_max * WORKLOAD_HEADROOM_FACTOR;
1560    let cpu_machines = (cpu_with_headroom / allocatable_cpu(instance)).ceil() as u32;
1561
1562    let mem_with_headroom =
1563        requirements.total_memory_bytes_at_max as f64 * WORKLOAD_HEADROOM_FACTOR;
1564    let mem_machines =
1565        (mem_with_headroom / allocatable_memory_bytes(instance) as f64).ceil() as u32;
1566
1567    // Take the larger of CPU-based and memory-based, clamped to cluster limit
1568    cpu_machines
1569        .max(mem_machines)
1570        .max(1)
1571        .min(MAX_MACHINES_PER_CLUSTER)
1572}
1573
1574/// Calculate minimum machines for HA.
1575fn compute_min_machines(
1576    requirements: &WorkloadRequirements,
1577    instance: &InstanceTypeSpec,
1578    max_machines: u32,
1579) -> u32 {
1580    let cpu_with_headroom = requirements.total_cpu_at_desired * WORKLOAD_HEADROOM_FACTOR;
1581    let cpu_machines = (cpu_with_headroom / allocatable_cpu(instance)).ceil() as u32;
1582
1583    let mem_with_headroom =
1584        requirements.total_memory_bytes_at_desired as f64 * WORKLOAD_HEADROOM_FACTOR;
1585    let mem_machines =
1586        (mem_with_headroom / allocatable_memory_bytes(instance) as f64).ceil() as u32;
1587
1588    cpu_machines
1589        .max(mem_machines)
1590        .max(1)
1591        .min(2)
1592        .min(max_machines)
1593}
1594
1595fn desired_target_machines(requirements: &WorkloadRequirements) -> u32 {
1596    if requirements.total_cpu_at_desired >= 2.0
1597        || requirements.total_memory_bytes_at_desired >= 4 * GI
1598    {
1599        2
1600    } else {
1601        1
1602    }
1603}
1604
1605fn allocatable_cpu(instance: &InstanceTypeSpec) -> f64 {
1606    (instance.vcpu as f64 - SYSTEM_RESERVE_CPU).max(0.25)
1607}
1608
1609fn allocatable_memory_bytes(instance: &InstanceTypeSpec) -> u64 {
1610    instance
1611        .memory_bytes
1612        .saturating_sub(system_reserve_memory_bytes(instance.memory_bytes))
1613        .max(256 * MI)
1614}
1615
1616fn system_reserve_memory_bytes(memory_bytes: u64) -> u64 {
1617    if memory_bytes < 4 * GI {
1618        256 * MI
1619    } else if memory_bytes < 16 * GI {
1620        512 * MI
1621    } else {
1622        GI
1623    }
1624}
1625
1626// ---------------------------------------------------------------------------
1627// Tests
1628// ---------------------------------------------------------------------------
1629
1630#[cfg(test)]
1631mod tests {
1632    use super::*;
1633    use crate::BinaryTarget;
1634
1635    // -- Parsing tests --
1636
1637    #[test]
1638    fn test_parse_cpu_plain() {
1639        assert_eq!(parse_cpu("1").unwrap(), 1.0);
1640        assert_eq!(parse_cpu("0.5").unwrap(), 0.5);
1641        assert_eq!(parse_cpu("2.0").unwrap(), 2.0);
1642        assert_eq!(parse_cpu("16").unwrap(), 16.0);
1643    }
1644
1645    #[test]
1646    fn test_parse_cpu_millicore() {
1647        assert_eq!(parse_cpu("500m").unwrap(), 0.5);
1648        assert_eq!(parse_cpu("250m").unwrap(), 0.25);
1649        assert_eq!(parse_cpu("1000m").unwrap(), 1.0);
1650        assert_eq!(parse_cpu("100m").unwrap(), 0.1);
1651    }
1652
1653    #[test]
1654    fn test_parse_cpu_invalid() {
1655        assert!(parse_cpu("").is_err());
1656        assert!(parse_cpu("abc").is_err());
1657        assert!(parse_cpu("m").is_err());
1658    }
1659
1660    #[test]
1661    fn test_parse_memory_binary_suffixes() {
1662        assert_eq!(parse_memory_bytes("1Ki").unwrap(), 1024);
1663        assert_eq!(parse_memory_bytes("1Mi").unwrap(), 1024 * 1024);
1664        assert_eq!(parse_memory_bytes("1Gi").unwrap(), 1024 * 1024 * 1024);
1665        assert_eq!(parse_memory_bytes("4Gi").unwrap(), 4 * 1024 * 1024 * 1024);
1666        assert_eq!(parse_memory_bytes("512Mi").unwrap(), 512 * 1024 * 1024);
1667        assert_eq!(
1668            parse_memory_bytes("1Ti").unwrap(),
1669            1024u64 * 1024 * 1024 * 1024
1670        );
1671    }
1672
1673    #[test]
1674    fn test_parse_memory_decimal_suffixes() {
1675        assert_eq!(parse_memory_bytes("1k").unwrap(), 1000);
1676        assert_eq!(parse_memory_bytes("1M").unwrap(), 1_000_000);
1677        assert_eq!(parse_memory_bytes("1G").unwrap(), 1_000_000_000);
1678        assert_eq!(parse_memory_bytes("1T").unwrap(), 1_000_000_000_000);
1679    }
1680
1681    #[test]
1682    fn test_parse_memory_plain_bytes() {
1683        assert_eq!(parse_memory_bytes("1024").unwrap(), 1024);
1684        assert_eq!(parse_memory_bytes("0").unwrap(), 0);
1685    }
1686
1687    #[test]
1688    fn test_parse_memory_invalid() {
1689        assert!(parse_memory_bytes("").is_err());
1690        assert!(parse_memory_bytes("abc").is_err());
1691        assert!(parse_memory_bytes("Gi").is_err());
1692    }
1693
1694    #[test]
1695    fn test_parse_memory_fractional() {
1696        assert_eq!(parse_memory_bytes("0.5Gi").unwrap(), GI / 2);
1697        assert_eq!(parse_memory_bytes("1.5Gi").unwrap(), GI + GI / 2);
1698    }
1699
1700    // -- Catalog lookup tests --
1701
1702    #[test]
1703    fn test_catalog_has_entries_for_all_cloud_platforms() {
1704        assert!(!catalog_for_platform(Platform::Aws).is_empty());
1705        assert!(!catalog_for_platform(Platform::Gcp).is_empty());
1706        assert!(!catalog_for_platform(Platform::Azure).is_empty());
1707    }
1708
1709    #[test]
1710    fn test_catalog_no_entries_for_non_cloud_platforms() {
1711        assert!(catalog_for_platform(Platform::Local).is_empty());
1712        assert!(catalog_for_platform(Platform::Kubernetes).is_empty());
1713    }
1714
1715    #[test]
1716    fn test_find_known_instance_type() {
1717        let spec =
1718            find_instance_type(Platform::Aws, "m7g.2xlarge").expect("should find m7g.2xlarge");
1719        assert_eq!(spec.vcpu, 8);
1720        assert_eq!(spec.memory_bytes, 32 * GI);
1721        assert_eq!(spec.family, InstanceFamily::GeneralPurpose);
1722    }
1723
1724    #[test]
1725    fn test_find_aws_c8i_nested_virt_instance_type() {
1726        let spec = find_instance_type(Platform::Aws, "c8i.large").expect("should find c8i.large");
1727        assert_eq!(spec.vcpu, 2);
1728        assert_eq!(spec.memory_bytes, 4 * GI);
1729        assert_eq!(spec.family, InstanceFamily::ComputeOptimized);
1730        assert_eq!(spec.architecture, Architecture::X86_64);
1731        assert!(spec.is_nested_virt_capable());
1732    }
1733
1734    #[test]
1735    fn test_find_unknown_instance_type() {
1736        assert!(find_instance_type(Platform::Aws, "nonexistent.xlarge").is_none());
1737    }
1738
1739    #[test]
1740    fn test_find_wrong_platform() {
1741        assert!(find_instance_type(Platform::Gcp, "m7g.2xlarge").is_none());
1742    }
1743
1744    #[test]
1745    fn test_to_machine_profile() {
1746        let spec = find_instance_type(Platform::Aws, "m7g.2xlarge").unwrap();
1747        let profile = spec.to_machine_profile();
1748        assert_eq!(profile.cpu, "8.0");
1749        assert_eq!(profile.memory_bytes, 32 * GI);
1750        assert_eq!(profile.ephemeral_storage_bytes, 20 * GI);
1751        assert!(profile.gpu.is_none());
1752    }
1753
1754    #[test]
1755    fn test_to_machine_profile_with_gpu() {
1756        let spec = find_instance_type(Platform::Aws, "p4d.24xlarge").unwrap();
1757        let profile = spec.to_machine_profile();
1758        let gpu = profile.gpu.as_ref().expect("should have GPU");
1759        assert_eq!(gpu.gpu_type, "nvidia-a100");
1760        assert_eq!(gpu.count, 8);
1761    }
1762
1763    // -- Selection algorithm tests --
1764
1765    #[test]
1766    fn test_select_burstable_for_small_workload() {
1767        let req = WorkloadRequirements {
1768            total_cpu_at_desired: 1.0,
1769            total_memory_bytes_at_desired: 2 * GI,
1770            total_cpu_at_max: 1.0,
1771            total_memory_bytes_at_max: 2 * GI,
1772            max_cpu_per_container: 0.5,
1773            max_memory_per_container: 1 * GI,
1774            max_ephemeral_storage_bytes: 10 * GI,
1775            gpu: None,
1776            architecture: None,
1777            nested_virt: false,
1778        };
1779        let sel = select_instance_type(Platform::Aws, &req).unwrap();
1780        let spec = find_instance_type(Platform::Aws, sel.instance_type).unwrap();
1781        assert_eq!(spec.family, InstanceFamily::Burstable);
1782    }
1783
1784    #[test]
1785    fn test_selects_smallest_burstable_machine_with_real_headroom() {
1786        let req = WorkloadRequirements {
1787            total_cpu_at_desired: 1.0,
1788            total_memory_bytes_at_desired: 2 * GI,
1789            total_cpu_at_max: 1.0,
1790            total_memory_bytes_at_max: 2 * GI,
1791            max_cpu_per_container: 1.0,
1792            max_memory_per_container: 2 * GI,
1793            max_ephemeral_storage_bytes: 10 * GI,
1794            gpu: None,
1795            architecture: None,
1796            nested_virt: false,
1797        };
1798
1799        let selection = select_instance_type(Platform::Aws, &req).unwrap();
1800
1801        assert_eq!(selection.instance_type, "t4g.medium");
1802        assert_eq!(selection.min_machines, 1);
1803        assert_eq!(selection.max_machines, 1);
1804    }
1805
1806    #[test]
1807    fn test_select_general_purpose_for_standard_workload() {
1808        // Standard workloads always get GeneralPurpose regardless of CPU:memory ratio
1809        let req = WorkloadRequirements {
1810            total_cpu_at_desired: 20.0,
1811            total_memory_bytes_at_desired: 80 * GI,
1812            total_cpu_at_max: 20.0,
1813            total_memory_bytes_at_max: 80 * GI,
1814            max_cpu_per_container: 2.0,
1815            max_memory_per_container: 8 * GI,
1816            max_ephemeral_storage_bytes: 10 * GI,
1817            gpu: None,
1818            architecture: None,
1819            nested_virt: false,
1820        };
1821        let sel = select_instance_type(Platform::Aws, &req).unwrap();
1822        let spec = find_instance_type(Platform::Aws, sel.instance_type).unwrap();
1823        assert_eq!(spec.family, InstanceFamily::GeneralPurpose);
1824    }
1825
1826    #[test]
1827    fn test_select_general_purpose_even_for_cpu_heavy() {
1828        // CPU-heavy workloads still get GeneralPurpose (no more ComputeOptimized auto-select)
1829        let req = WorkloadRequirements {
1830            total_cpu_at_desired: 20.0,
1831            total_memory_bytes_at_desired: 20 * GI,
1832            total_cpu_at_max: 20.0,
1833            total_memory_bytes_at_max: 20 * GI,
1834            max_cpu_per_container: 2.0,
1835            max_memory_per_container: 2 * GI,
1836            max_ephemeral_storage_bytes: 10 * GI,
1837            gpu: None,
1838            architecture: None,
1839            nested_virt: false,
1840        };
1841        let sel = select_instance_type(Platform::Aws, &req).unwrap();
1842        let spec = find_instance_type(Platform::Aws, sel.instance_type).unwrap();
1843        assert_eq!(spec.family, InstanceFamily::GeneralPurpose);
1844    }
1845
1846    #[test]
1847    fn test_select_storage_optimized_for_large_ephemeral() {
1848        let req = WorkloadRequirements {
1849            total_cpu_at_desired: 8.0,
1850            total_memory_bytes_at_desired: 32 * GI,
1851            total_cpu_at_max: 8.0,
1852            total_memory_bytes_at_max: 32 * GI,
1853            max_cpu_per_container: 2.0,
1854            max_memory_per_container: 8 * GI,
1855            max_ephemeral_storage_bytes: 500 * GI,
1856            gpu: None,
1857            architecture: Some(Architecture::X86_64),
1858            nested_virt: false,
1859        };
1860        let sel = select_instance_type(Platform::Aws, &req).unwrap();
1861        let spec = find_instance_type(Platform::Aws, sel.instance_type).unwrap();
1862        assert_eq!(spec.family, InstanceFamily::StorageOptimized);
1863    }
1864
1865    #[test]
1866    fn test_select_configurable_storage_above_fixed_local_catalog() {
1867        let req = WorkloadRequirements {
1868            total_cpu_at_desired: 8.0,
1869            total_memory_bytes_at_desired: 32 * GI,
1870            total_cpu_at_max: 8.0,
1871            total_memory_bytes_at_max: 32 * GI,
1872            max_cpu_per_container: 2.0,
1873            max_memory_per_container: 8 * GI,
1874            max_ephemeral_storage_bytes: 8_000 * GI,
1875            gpu: None,
1876            architecture: Some(Architecture::X86_64),
1877            nested_virt: false,
1878        };
1879        let sel = select_instance_type(Platform::Aws, &req).unwrap();
1880        let spec = find_instance_type(Platform::Aws, sel.instance_type).unwrap();
1881        assert_eq!(spec.family, InstanceFamily::GeneralPurpose);
1882        assert_eq!(sel.profile.ephemeral_storage_bytes, 8_000 * GI);
1883    }
1884
1885    #[test]
1886    fn test_configurable_storage_fallback_retains_standard_vcpu_cap() {
1887        let req = WorkloadRequirements {
1888            total_cpu_at_desired: 70.0,
1889            total_memory_bytes_at_desired: 140 * GI,
1890            total_cpu_at_max: 70.0,
1891            total_memory_bytes_at_max: 140 * GI,
1892            max_cpu_per_container: 2.0,
1893            max_memory_per_container: 4 * GI,
1894            max_ephemeral_storage_bytes: 8_000 * GI,
1895            gpu: None,
1896            architecture: Some(Architecture::X86_64),
1897            nested_virt: false,
1898        };
1899
1900        let sel = select_instance_type(Platform::Aws, &req).unwrap();
1901        let spec = find_instance_type(Platform::Aws, sel.instance_type).unwrap();
1902        assert_eq!(spec.family, InstanceFamily::GeneralPurpose);
1903        assert!(spec.vcpu <= MAX_STANDARD_VCPU);
1904        assert!(sel.max_machines > 1);
1905    }
1906
1907    #[test]
1908    fn test_nested_virtualization_storage_falls_back_to_configurable_disk() {
1909        for platform in [Platform::Aws, Platform::Gcp, Platform::Azure] {
1910            let req = WorkloadRequirements {
1911                total_cpu_at_desired: 2.0,
1912                total_memory_bytes_at_desired: 8 * GI,
1913                total_cpu_at_max: 2.0,
1914                total_memory_bytes_at_max: 8 * GI,
1915                max_cpu_per_container: 2.0,
1916                max_memory_per_container: 8 * GI,
1917                max_ephemeral_storage_bytes: 500 * GI,
1918                gpu: None,
1919                architecture: Some(Architecture::X86_64),
1920                nested_virt: true,
1921            };
1922
1923            let sel = select_instance_type(platform, &req)
1924                .unwrap_or_else(|error| panic!("{platform} should support fallback: {error}"));
1925            let spec = find_instance_type(platform, sel.instance_type).unwrap();
1926            assert_eq!(spec.family, InstanceFamily::GeneralPurpose);
1927            assert!(spec.is_nested_virt_capable());
1928            assert_eq!(sel.profile.ephemeral_storage_bytes, 500 * GI);
1929        }
1930    }
1931
1932    #[test]
1933    fn test_configurable_disk_rejects_capacity_above_provider_limit() {
1934        for (platform, requested) in [
1935            (Platform::Aws, 100 * TI),
1936            (Platform::Gcp, 100 * TI),
1937            (Platform::Azure, 8_000 * GI),
1938        ] {
1939            let req = WorkloadRequirements {
1940                total_cpu_at_desired: 2.0,
1941                total_memory_bytes_at_desired: 8 * GI,
1942                total_cpu_at_max: 2.0,
1943                total_memory_bytes_at_max: 8 * GI,
1944                max_cpu_per_container: 2.0,
1945                max_memory_per_container: 8 * GI,
1946                max_ephemeral_storage_bytes: requested,
1947                gpu: None,
1948                architecture: Some(Architecture::X86_64),
1949                nested_virt: true,
1950            };
1951
1952            assert!(select_instance_type(platform, &req).is_err());
1953        }
1954    }
1955
1956    #[test]
1957    fn test_configurable_disk_limits_account_for_controller_rounding() {
1958        for (platform, disk_limit_gib) in [
1959            (Platform::Aws, 64 * 1024),
1960            (Platform::Gcp, 64 * 1024),
1961            (Platform::Azure, 4_095),
1962        ] {
1963            let max = max_configurable_ephemeral_storage_bytes(platform).unwrap();
1964            let materialized_disk_gib = |requested_bytes: u64| {
1965                let requested_gib = requested_bytes.div_ceil(GI);
1966                (requested_gib * 5).div_ceil(4) + 12
1967            };
1968
1969            assert!(materialized_disk_gib(max) <= disk_limit_gib);
1970            assert!(materialized_disk_gib(max + 1) > disk_limit_gib);
1971        }
1972    }
1973
1974    #[test]
1975    fn test_gpu_local_storage_is_not_treated_as_resizable() {
1976        let spec = find_instance_type(Platform::Aws, "g5.xlarge").unwrap();
1977        assert_eq!(spec.family, InstanceFamily::GpuCompute);
1978        assert!(!spec.has_configurable_ephemeral_storage());
1979    }
1980
1981    #[test]
1982    fn test_select_gpu_instance() {
1983        let req = WorkloadRequirements {
1984            total_cpu_at_desired: 8.0,
1985            total_memory_bytes_at_desired: 32 * GI,
1986            total_cpu_at_max: 8.0,
1987            total_memory_bytes_at_max: 32 * GI,
1988            max_cpu_per_container: 4.0,
1989            max_memory_per_container: 16 * GI,
1990            max_ephemeral_storage_bytes: 10 * GI,
1991            gpu: Some(GpuSpec {
1992                gpu_type: "nvidia-a100".to_string(),
1993                count: 1,
1994            }),
1995            architecture: Some(Architecture::X86_64),
1996            nested_virt: false,
1997        };
1998        let sel = select_instance_type(Platform::Aws, &req).unwrap();
1999        let spec = find_instance_type(Platform::Aws, sel.instance_type).unwrap();
2000        assert_eq!(spec.family, InstanceFamily::GpuCompute);
2001        assert!(spec.gpu.is_some());
2002    }
2003
2004    #[test]
2005    fn test_select_uses_each_cloud_image_target_architecture() {
2006        let req = WorkloadRequirements {
2007            total_cpu_at_desired: 4.0,
2008            total_memory_bytes_at_desired: 16 * GI,
2009            total_cpu_at_max: 4.0,
2010            total_memory_bytes_at_max: 16 * GI,
2011            max_cpu_per_container: 1.0,
2012            max_memory_per_container: 4 * GI,
2013            max_ephemeral_storage_bytes: 10 * GI,
2014            gpu: None,
2015            architecture: None,
2016            nested_virt: false,
2017        };
2018        for platform in [Platform::Aws, Platform::Gcp, Platform::Azure] {
2019            let sel = select_instance_type(platform, &req)
2020                .unwrap_or_else(|error| panic!("selection failed for {platform}: {error}"));
2021            let spec = find_instance_type(platform, sel.instance_type)
2022                .expect("selected machine should exist in the catalog");
2023            assert_eq!(
2024                Some(spec.architecture),
2025                default_architecture(platform),
2026                "machine architecture must match the image target for {platform}"
2027            );
2028        }
2029    }
2030
2031    #[test]
2032    fn test_machine_count_reasonable() {
2033        // Single container: 1 CPU, 2Gi, maxReplicas=20
2034        let req = WorkloadRequirements {
2035            total_cpu_at_desired: 20.0,
2036            total_memory_bytes_at_desired: 40 * GI,
2037            total_cpu_at_max: 20.0,
2038            total_memory_bytes_at_max: 40 * GI,
2039            max_cpu_per_container: 1.0,
2040            max_memory_per_container: 2 * GI,
2041            max_ephemeral_storage_bytes: 10 * GI,
2042            gpu: None,
2043            architecture: None,
2044            nested_virt: false,
2045        };
2046        let sel = select_instance_type(Platform::Aws, &req).unwrap();
2047        assert!(sel.min_machines >= 1);
2048        assert!(sel.max_machines <= MAX_MACHINES_PER_CLUSTER);
2049        assert!(sel.max_machines >= sel.min_machines);
2050    }
2051
2052    #[test]
2053    fn test_instance_size_capped_at_8_vcpu() {
2054        // Even with very large containers, instance size is capped at 8 vCPUs
2055        let req = WorkloadRequirements {
2056            total_cpu_at_desired: 70.0,
2057            total_memory_bytes_at_desired: 140 * GI,
2058            total_cpu_at_max: 70.0,
2059            total_memory_bytes_at_max: 140 * GI,
2060            max_cpu_per_container: 2.0,
2061            max_memory_per_container: 4 * GI,
2062            max_ephemeral_storage_bytes: 10 * GI,
2063            gpu: None,
2064            architecture: None,
2065            nested_virt: false,
2066        };
2067        let sel = select_instance_type(Platform::Gcp, &req).unwrap();
2068        let spec = find_instance_type(Platform::Gcp, sel.instance_type).unwrap();
2069        assert!(
2070            spec.vcpu <= MAX_STANDARD_VCPU,
2071            "selected {} with {} vCPUs, expected <= {}",
2072            spec.name,
2073            spec.vcpu,
2074            MAX_STANDARD_VCPU
2075        );
2076        assert_eq!(spec.family, InstanceFamily::GeneralPurpose);
2077        // Should scale horizontally instead
2078        assert!(sel.max_machines > 1);
2079    }
2080
2081    #[test]
2082    fn test_larger_autoscaled_workload_gets_reasonable_instance() {
2083        // Simulates a larger autoscaled workload: 4 containers, each 2 CPU / 4 GiB
2084        // maxReplicas: 10, 10, 10, 5
2085        let req = WorkloadRequirements {
2086            total_cpu_at_desired: 70.0,
2087            total_memory_bytes_at_desired: 140 * GI,
2088            total_cpu_at_max: 70.0,              // 2*10 + 2*10 + 2*10 + 2*5
2089            total_memory_bytes_at_max: 140 * GI, // 4*10 + 4*10 + 4*10 + 4*5
2090            max_cpu_per_container: 2.0,
2091            max_memory_per_container: 4 * GI,
2092            max_ephemeral_storage_bytes: 20 * GI,
2093            gpu: None,
2094            architecture: None,
2095            nested_virt: false,
2096        };
2097        let sel = select_instance_type(Platform::Gcp, &req).unwrap();
2098        // Should pick n2-standard-8 (8 vCPU, 32 GiB) — NOT c3-standard-44
2099        assert_eq!(sel.instance_type, "n2-standard-8");
2100        assert!(sel.max_machines >= 2);
2101    }
2102
2103    /// When `nested_virt` is set on the workload, the selector must
2104    /// restrict to nested-virt-capable families. On AWS that means an m8i
2105    /// (or other 8th-gen Intel) entry, never a Graviton (`*7g`, `t4g`) or
2106    /// burstable. Without this filter the launch template gets created
2107    /// with `CpuOptions.NestedVirtualization=enabled` paired with an
2108    /// instance type AWS rejects at RunInstances.
2109    #[test]
2110    fn test_select_aws_picks_m8i_when_nested_virt_required() {
2111        let req = WorkloadRequirements {
2112            total_cpu_at_desired: 4.0,
2113            total_memory_bytes_at_desired: 8 * GI,
2114            total_cpu_at_max: 4.0,
2115            total_memory_bytes_at_max: 8 * GI,
2116            max_cpu_per_container: 4.0,
2117            max_memory_per_container: 8 * GI,
2118            max_ephemeral_storage_bytes: 10 * GI,
2119            gpu: None,
2120            architecture: Some(Architecture::X86_64),
2121            nested_virt: true,
2122        };
2123        let sel = select_instance_type(Platform::Aws, &req).unwrap();
2124        assert!(
2125            sel.instance_type.starts_with("m8i.")
2126                || sel.instance_type.starts_with("c8i.")
2127                || sel.instance_type.starts_with("r8i."),
2128            "expected an m8i/c8i/r8i instance, got {}",
2129            sel.instance_type
2130        );
2131        let spec = find_instance_type(Platform::Aws, sel.instance_type).unwrap();
2132        assert!(spec.is_nested_virt_capable());
2133    }
2134
2135    #[test]
2136    fn test_select_gcp_picks_n2_when_nested_virt_required() {
2137        let req = WorkloadRequirements {
2138            total_cpu_at_desired: 4.0,
2139            total_memory_bytes_at_desired: 8 * GI,
2140            total_cpu_at_max: 4.0,
2141            total_memory_bytes_at_max: 8 * GI,
2142            max_cpu_per_container: 4.0,
2143            max_memory_per_container: 8 * GI,
2144            max_ephemeral_storage_bytes: 0,
2145            architecture: Some(Architecture::X86_64),
2146            gpu: None,
2147            nested_virt: true,
2148        };
2149
2150        let selection = select_instance_type(Platform::Gcp, &req).unwrap();
2151        assert_eq!(selection.instance_type, "n2-standard-8");
2152        assert!(find_instance_type(Platform::Gcp, selection.instance_type)
2153            .unwrap()
2154            .is_nested_virt_capable());
2155    }
2156
2157    #[test]
2158    fn test_select_azure_picks_dsv5_when_nested_virt_required() {
2159        let req = WorkloadRequirements {
2160            total_cpu_at_desired: 4.0,
2161            total_memory_bytes_at_desired: 8 * GI,
2162            total_cpu_at_max: 4.0,
2163            total_memory_bytes_at_max: 8 * GI,
2164            max_cpu_per_container: 4.0,
2165            max_memory_per_container: 8 * GI,
2166            max_ephemeral_storage_bytes: 0,
2167            architecture: Some(Architecture::X86_64),
2168            gpu: None,
2169            nested_virt: true,
2170        };
2171
2172        let selection = select_instance_type(Platform::Azure, &req).unwrap();
2173        assert_eq!(selection.instance_type, "Standard_D8s_v5");
2174        assert!(find_instance_type(Platform::Azure, selection.instance_type)
2175            .unwrap()
2176            .is_nested_virt_capable());
2177    }
2178
2179    #[test]
2180    fn test_select_aws_defaults_to_image_target_architecture() {
2181        let req = WorkloadRequirements {
2182            total_cpu_at_desired: 4.0,
2183            total_memory_bytes_at_desired: 8 * GI,
2184            total_cpu_at_max: 4.0,
2185            total_memory_bytes_at_max: 8 * GI,
2186            max_cpu_per_container: 4.0,
2187            max_memory_per_container: 8 * GI,
2188            max_ephemeral_storage_bytes: 10 * GI,
2189            gpu: None,
2190            architecture: None,
2191            nested_virt: false,
2192        };
2193        let sel = select_instance_type(Platform::Aws, &req).unwrap();
2194        let spec = find_instance_type(Platform::Aws, sel.instance_type).unwrap();
2195        assert_eq!(spec.architecture, Architecture::Arm64);
2196    }
2197
2198    #[test]
2199    fn test_cloud_defaults_match_image_target_architectures() {
2200        for platform in [Platform::Aws, Platform::Gcp, Platform::Azure] {
2201            let target = BinaryTarget::defaults_for_platform(platform)
2202                .into_iter()
2203                .next()
2204                .expect("managed cloud should have a default image target");
2205            let image_architecture = match target.oci_arch() {
2206                "arm64" => Architecture::Arm64,
2207                "amd64" => Architecture::X86_64,
2208                architecture => {
2209                    panic!("unsupported managed-cloud image architecture {architecture}")
2210                }
2211            };
2212
2213            assert_eq!(default_architecture(platform), Some(image_architecture));
2214        }
2215    }
2216
2217    /// ARM remains available when the workload or capacity profile declares it.
2218    #[test]
2219    fn test_select_aws_uses_graviton_for_explicit_arm64() {
2220        let req = WorkloadRequirements {
2221            total_cpu_at_desired: 4.0,
2222            total_memory_bytes_at_desired: 8 * GI,
2223            total_cpu_at_max: 4.0,
2224            total_memory_bytes_at_max: 8 * GI,
2225            max_cpu_per_container: 4.0,
2226            max_memory_per_container: 8 * GI,
2227            max_ephemeral_storage_bytes: 10 * GI,
2228            gpu: None,
2229            architecture: Some(Architecture::Arm64),
2230            nested_virt: false,
2231        };
2232        let sel = select_instance_type(Platform::Aws, &req).unwrap();
2233        let spec = find_instance_type(Platform::Aws, sel.instance_type).unwrap();
2234        assert_eq!(spec.architecture, Architecture::Arm64);
2235    }
2236
2237    #[test]
2238    fn test_select_rejects_explicit_architecture_missing_from_cloud_catalog() {
2239        let req = WorkloadRequirements {
2240            total_cpu_at_desired: 1.0,
2241            total_memory_bytes_at_desired: 2 * GI,
2242            total_cpu_at_max: 1.0,
2243            total_memory_bytes_at_max: 2 * GI,
2244            max_cpu_per_container: 1.0,
2245            max_memory_per_container: 2 * GI,
2246            max_ephemeral_storage_bytes: 10 * GI,
2247            gpu: None,
2248            architecture: Some(Architecture::Arm64),
2249            nested_virt: false,
2250        };
2251
2252        let error = select_instance_type(Platform::Gcp, &req)
2253            .expect_err("GCP catalog has no ARM64 machine");
2254
2255        assert!(error.contains("architecture Arm64 is unavailable"));
2256    }
2257
2258    #[test]
2259    fn test_profile_has_required_fields() {
2260        let req = WorkloadRequirements {
2261            total_cpu_at_desired: 4.0,
2262            total_memory_bytes_at_desired: 16 * GI,
2263            total_cpu_at_max: 4.0,
2264            total_memory_bytes_at_max: 16 * GI,
2265            max_cpu_per_container: 1.0,
2266            max_memory_per_container: 4 * GI,
2267            max_ephemeral_storage_bytes: 10 * GI,
2268            gpu: None,
2269            architecture: None,
2270            nested_virt: false,
2271        };
2272        let sel = select_instance_type(Platform::Aws, &req).unwrap();
2273        assert!(!sel.profile.cpu.is_empty());
2274        assert!(sel.profile.memory_bytes > 0);
2275        assert!(sel.profile.ephemeral_storage_bytes > 0);
2276    }
2277
2278    #[test]
2279    fn test_error_for_unsupported_gpu_type() {
2280        let req = WorkloadRequirements {
2281            total_cpu_at_desired: 8.0,
2282            total_memory_bytes_at_desired: 32 * GI,
2283            total_cpu_at_max: 8.0,
2284            total_memory_bytes_at_max: 32 * GI,
2285            max_cpu_per_container: 4.0,
2286            max_memory_per_container: 16 * GI,
2287            max_ephemeral_storage_bytes: 10 * GI,
2288            gpu: Some(GpuSpec {
2289                gpu_type: "amd-mi300".to_string(),
2290                count: 1,
2291            }),
2292            architecture: None,
2293            nested_virt: false,
2294        };
2295        let result = select_instance_type(Platform::Aws, &req);
2296        assert!(result.is_err());
2297    }
2298
2299    #[test]
2300    fn test_catalog_instance_types_sorted_by_vcpu_within_family() {
2301        // Verify that within each (platform, family) group, vcpu is non-decreasing.
2302        // This ensures our "min_by_key(vcpu)" logic works correctly.
2303        for platform in [Platform::Aws, Platform::Gcp, Platform::Azure] {
2304            let entries = catalog_for_platform(platform);
2305            let mut by_family: std::collections::HashMap<_, Vec<_>> =
2306                std::collections::HashMap::new();
2307            for entry in entries {
2308                by_family
2309                    .entry(format!("{:?}", entry.family))
2310                    .or_default()
2311                    .push(entry);
2312            }
2313            for (family, instances) in &by_family {
2314                for window in instances.windows(2) {
2315                    assert!(
2316                        window[0].vcpu <= window[1].vcpu,
2317                        "catalog not sorted by vcpu for {platform}/{family}: {} ({}) > {} ({})",
2318                        window[0].name,
2319                        window[0].vcpu,
2320                        window[1].name,
2321                        window[1].vcpu
2322                    );
2323                }
2324            }
2325        }
2326    }
2327}