Skip to main content

alien_core/resources/
compute_cluster.rs

1//! ComputeCluster resource for long-running container workloads.
2//!
3//! A ComputeCluster represents the setup-owned compute boundary for containers.
4//! Setup provisions:
5//! - Auto Scaling Groups (AWS), Managed Instance Groups (GCP), or VM Scale Sets (Azure)
6//! - IAM roles/service accounts for machine authentication
7//! - Security groups/firewall rules
8//! - Launch templates/instance configurations
9
10use crate::error::{ErrorData, Result};
11use crate::instance_catalog::{is_same_architecture_machine, Architecture};
12use crate::resource::{ResourceDefinition, ResourceOutputsDefinition, ResourceRef};
13use crate::{PermissionProfile, Platform, ResourceType};
14use alien_error::AlienError;
15use bon::Builder;
16use serde::{Deserialize, Serialize};
17use std::any::Any;
18use std::collections::BTreeMap;
19use std::fmt::Debug;
20
21/// GPU specification for a capacity group.
22#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
23#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
24#[serde(rename_all = "camelCase")]
25pub struct GpuSpec {
26    /// GPU type identifier (e.g., "nvidia-a100", "nvidia-t4")
27    #[serde(rename = "type")]
28    pub gpu_type: String,
29    /// Number of GPUs per machine
30    pub count: u32,
31}
32
33/// Machine resource profile for a capacity group.
34///
35/// Represents the hardware specifications for machines in a capacity group.
36/// These are hardware totals (what the instance type advertises), not allocatable
37/// capacity. The managed container scheduler internally subtracts system reserves for planning.
38#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
39#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
40#[serde(rename_all = "camelCase")]
41pub struct MachineProfile {
42    /// CPU cores per machine (hardware total) - stored as string to preserve precision
43    /// (e.g., "8.0", "4.5")
44    pub cpu: String,
45    /// Memory in bytes (hardware total)
46    pub memory_bytes: u64,
47    /// Ephemeral storage in bytes (hardware total)
48    pub ephemeral_storage_bytes: u64,
49    /// CPU architecture required or provided by this machine profile.
50    #[serde(skip_serializing_if = "Option::is_none")]
51    pub architecture: Option<Architecture>,
52    /// GPU specification (optional)
53    #[serde(skip_serializing_if = "Option::is_none")]
54    pub gpu: Option<GpuSpec>,
55    /// Additional empty block device each machine must provide, besides the
56    /// disk holding the operating system. On AWS, GCP and Azure the runtime
57    /// controller attaches one to every machine; on Machines, `join` refuses a
58    /// host without one. Kubernetes rejects it.
59    #[serde(default, skip_serializing_if = "Option::is_none")]
60    pub data_disk: Option<DataDiskRequirement>,
61}
62
63/// An additional block device a machine must provide with no partitions,
64/// no filesystem signature, and no mounts, so the workload can format it.
65#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
66#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
67#[serde(rename_all = "camelCase")]
68pub struct DataDiskRequirement {
69    /// Minimum device size in bytes.
70    pub size_bytes: u64,
71}
72
73/// Allowed range and default for a count selected by the installer.
74#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
75#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
76#[serde(rename_all = "camelCase")]
77pub struct ComputeChoiceRange {
78    /// Lowest allowed value.
79    pub min: u32,
80    /// Highest allowed value.
81    pub max: u32,
82    /// Default value recommended when no installer override is supplied.
83    pub default: u32,
84}
85
86impl ComputeChoiceRange {
87    /// Returns whether a selected value is inside the allowed range.
88    pub fn contains(&self, value: u32) -> bool {
89        self.min <= value && value <= self.max
90    }
91}
92
93/// Source-declared scale policy for a capacity group.
94#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
95#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
96#[serde(rename_all = "camelCase", tag = "type")]
97pub enum CapacityGroupScalePolicy {
98    /// A fixed-size pool where the installer can choose the fixed machine count.
99    Fixed {
100        /// Allowed fixed machine count range.
101        machines: ComputeChoiceRange,
102    },
103    /// An autoscaling pool with separately bounded min and max counts.
104    Autoscale {
105        /// Allowed minimum machine count range.
106        min: ComputeChoiceRange,
107        /// Allowed maximum machine count range.
108        max: ComputeChoiceRange,
109    },
110}
111
112impl CapacityGroupScalePolicy {
113    /// Derive the legacy policy represented by selected min/max values.
114    pub fn from_selected_bounds(min_size: u32, max_size: u32) -> Self {
115        if min_size == max_size {
116            Self::Fixed {
117                machines: ComputeChoiceRange {
118                    min: min_size,
119                    max: max_size,
120                    default: min_size,
121                },
122            }
123        } else {
124            Self::Autoscale {
125                min: ComputeChoiceRange {
126                    min: min_size,
127                    max: min_size,
128                    default: min_size,
129                },
130                max: ComputeChoiceRange {
131                    min: max_size,
132                    max: max_size,
133                    default: max_size,
134                },
135            }
136        }
137    }
138
139    /// Default selected min bound.
140    pub fn default_min_size(&self) -> u32 {
141        match self {
142            Self::Fixed { machines } => machines.default,
143            Self::Autoscale { min, .. } => min.default,
144        }
145    }
146
147    /// Default selected max bound.
148    pub fn default_max_size(&self) -> u32 {
149        match self {
150            Self::Fixed { machines } => machines.default,
151            Self::Autoscale { max, .. } => max.default,
152        }
153    }
154}
155
156/// Capacity group definition.
157///
158/// A capacity group represents machines with identical hardware profiles.
159/// Each group becomes a separate Auto Scaling Group (AWS), Managed Instance Group (GCP),
160/// or VM Scale Set (Azure).
161#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
162#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
163#[serde(rename_all = "camelCase")]
164pub struct CapacityGroup {
165    /// Unique identifier for this capacity group (must be lowercase alphanumeric with hyphens)
166    pub group_id: String,
167    /// Provider machine selected at deployment time.
168    /// `alien.ts` should declare portable requirements; preflight materialization
169    /// fills this field from `StackSettings.compute`.
170    #[serde(skip_serializing_if = "Option::is_none")]
171    pub instance_type: Option<String>,
172    /// Machine resource profile (auto-derived from instance_type if not specified)
173    #[serde(skip_serializing_if = "Option::is_none")]
174    pub profile: Option<MachineProfile>,
175    /// Minimum number of machines (can be 0 for scale-to-zero)
176    pub min_size: u32,
177    /// Maximum number of machines (must be ≤ 10)
178    pub max_size: u32,
179    /// Source-declared scale policy and installer-editable bounds.
180    ///
181    /// Older stacks only have `minSize` and `maxSize`; planners derive an exact
182    /// fixed/autoscale policy from those selected bounds when this field is absent.
183    #[serde(skip_serializing_if = "Option::is_none")]
184    pub scale_policy: Option<CapacityGroupScalePolicy>,
185    /// Require instance types that expose nested virtualization (VT-x/EPT)
186    /// to guest VMs. This is needed by workloads that boot nested VMs inside
187    /// containers.
188    /// When true, the controller's instance-type selector is constrained
189    /// to a vetted nested-virt-capable allowlist.
190    #[serde(skip_serializing_if = "Option::is_none")]
191    pub nested_virtualization: Option<bool>,
192}
193
194impl CapacityGroup {
195    /// AWS root volume size, including filesystem headroom and runtime overhead.
196    pub fn aws_root_volume_size_gib(&self) -> i32 {
197        self.aws_filesystem_size_gib()
198            .saturating_add(12)
199            .max(20)
200            .min(i32::MAX as u64) as i32
201    }
202
203    /// AWS second EBS volume: the declared data disk, or for a nested
204    /// virtualization pool without one, the volume such pools have always
205    /// received, sized from ephemeral storage.
206    pub fn aws_runtime_data_volume_size_gib(&self) -> Option<i32> {
207        if let Some(size_gib) = self.data_disk_size_gib() {
208            return Some(size_gib.min(i32::MAX as u64) as i32);
209        }
210        (self.nested_virtualization == Some(true))
211            .then(|| self.aws_filesystem_size_gib().max(20).min(i32::MAX as u64) as i32)
212    }
213
214    /// Whether `other` asks for different disks than this group: another root
215    /// storage size or data disk. Runtime controllers replace machines for it.
216    pub fn disks_differ(&self, other: &CapacityGroup) -> bool {
217        let disks = |group: &CapacityGroup| {
218            group
219                .profile
220                .as_ref()
221                .map(|profile| (profile.ephemeral_storage_bytes, profile.data_disk.clone()))
222        };
223        disks(self) != disks(other)
224    }
225
226    /// Declared data disk size, rounded up to a whole GiB.
227    pub fn data_disk_size_gib(&self) -> Option<u64> {
228        let data_disk = self.profile.as_ref()?.data_disk.as_ref()?;
229        Some(data_disk.size_bytes.div_ceil(1024 * 1024 * 1024))
230    }
231
232    fn aws_filesystem_size_gib(&self) -> u64 {
233        self.profile
234            .as_ref()
235            .map_or(0, |profile| profile.ephemeral_storage_bytes)
236            .div_ceil(1024 * 1024 * 1024)
237            .saturating_mul(5)
238            .div_ceil(4)
239    }
240}
241
242/// ComputeCluster resource for running long-running container workloads.
243///
244/// A ComputeCluster provides the setup-owned machine boundary for containers.
245/// Alien may manage the worker fleet inside that boundary when setup grants
246/// `compute-cluster/management`.
247///
248/// ## Architecture
249///
250/// - **Setup** creates cloud resources: ASGs/MIGs/VMSSs, IAM roles, security groups
251/// - **Alien** manages allowed fleet operations: machine count, runtime
252///   machine image rollout and, on AWS, the machine type within one CPU architecture
253/// - A node agent runs on each machine from the selected runtime image channel
254///
255/// ## Example
256///
257/// ```rust
258/// use alien_core::{CapacityGroup, ComputeCluster, MachineProfile};
259///
260/// let cluster = ComputeCluster::new("compute".to_string())
261///     .capacity_group(CapacityGroup {
262///         group_id: "general".to_string(),
263///         instance_type: None,
264///         profile: Some(MachineProfile {
265///             cpu: "4.0".to_string(),
266///             memory_bytes: 16 * 1024 * 1024 * 1024,
267///             ephemeral_storage_bytes: 20 * 1024 * 1024 * 1024,
268///             architecture: None,
269///             gpu: None,
270///             data_disk: None,
271///         }),
272///         min_size: 1,
273///         max_size: 5,
274///         scale_policy: None,
275///         nested_virtualization: None,
276///     })
277///     .build();
278/// ```
279#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Builder)]
280#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
281#[serde(rename_all = "camelCase", deny_unknown_fields)]
282#[builder(start_fn = new)]
283pub struct ComputeCluster {
284    /// Unique identifier for the container cluster.
285    /// Must contain only alphanumeric characters, hyphens, and underscores.
286    #[builder(start_fn)]
287    pub id: String,
288
289    /// Capacity groups defining the machine pools for this cluster.
290    /// Each group becomes a separate ASG/MIG/VMSS.
291    #[builder(field)]
292    pub capacity_groups: Vec<CapacityGroup>,
293
294    /// Explicit grants for the node identity, keyed by concrete resource ID.
295    /// Independent of workload permission profiles; absent grants no data access.
296    #[serde(default, skip_serializing_if = "Option::is_none")]
297    pub node_permissions: Option<PermissionProfile>,
298
299    /// Cloud platforms on which the explicit node grants apply; absent applies everywhere.
300    #[serde(default, skip_serializing_if = "Option::is_none")]
301    pub node_permissions_platforms: Option<Vec<Platform>>,
302
303    /// Pool reserved for containers created after a deployment is installed.
304    /// If absent, the runtime uses the `general` pool when it exists.
305    #[serde(default, skip_serializing_if = "Option::is_none")]
306    pub dynamic_container_pool: Option<String>,
307
308    /// Concrete provider failure domains selected during setup, keyed by capacity group.
309    /// Empty preserves the existing aggregate layout when no spread policy is configured.
310    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
311    #[builder(default)]
312    pub selected_failure_domains: BTreeMap<String, Vec<String>>,
313
314    /// Requested failure-domain spread keyed by capacity group.
315    /// Empty preserves the existing aggregate layout.
316    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
317    #[builder(default)]
318    pub failure_domain_spread: BTreeMap<String, u8>,
319
320    /// Container CIDR block for internal container networking.
321    /// Auto-generated as "10.244.0.0/16" if not specified.
322    /// Each machine gets a /24 subnet from this range.
323    #[serde(skip_serializing_if = "Option::is_none")]
324    pub container_cidr: Option<String>,
325}
326
327impl ComputeCluster {
328    /// The resource type identifier for ComputeCluster
329    pub const RESOURCE_TYPE: ResourceType = ResourceType::from_static("compute-cluster");
330
331    /// Returns the cluster's unique identifier.
332    pub fn id(&self) -> &str {
333        &self.id
334    }
335
336    /// Returns the container CIDR, defaulting to "10.244.0.0/16" if not specified.
337    pub fn container_cidr(&self) -> &str {
338        self.container_cidr.as_deref().unwrap_or("10.244.0.0/16")
339    }
340}
341
342impl<S: compute_cluster_builder::State> ComputeClusterBuilder<S> {
343    /// Adds a capacity group to the cluster.
344    pub fn capacity_group(mut self, group: CapacityGroup) -> Self {
345        self.capacity_groups.push(group);
346        self
347    }
348}
349
350/// Status of a single capacity group within a ComputeCluster.
351#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
352#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
353#[serde(rename_all = "camelCase")]
354pub struct CapacityGroupStatus {
355    /// Capacity group ID
356    pub group_id: String,
357    /// Current number of machines
358    pub current_machines: u32,
359    /// Desired number of machines (from the managed container capacity plan)
360    pub desired_machines: u32,
361    /// Instance type being used
362    pub instance_type: String,
363}
364
365/// Outputs generated by a successfully provisioned ComputeCluster.
366#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
367#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
368#[serde(rename_all = "camelCase")]
369pub struct ComputeClusterOutputs {
370    /// Managed container cluster ID (workspace/project/deployment/resourceid format)
371    pub cluster_id: String,
372    /// Whether the managed container cluster is ready
373    pub horizon_ready: bool,
374    /// Status of each capacity group
375    pub capacity_group_statuses: Vec<CapacityGroupStatus>,
376    /// Total number of machines across all capacity groups
377    pub total_machines: u32,
378}
379
380impl ResourceOutputsDefinition for ComputeClusterOutputs {
381    fn get_resource_type(&self) -> ResourceType {
382        ComputeCluster::RESOURCE_TYPE.clone()
383    }
384
385    fn as_any(&self) -> &dyn Any {
386        self
387    }
388
389    fn box_clone(&self) -> Box<dyn ResourceOutputsDefinition> {
390        Box::new(self.clone())
391    }
392
393    fn outputs_eq(&self, other: &dyn ResourceOutputsDefinition) -> bool {
394        other.as_any().downcast_ref::<ComputeClusterOutputs>() == Some(self)
395    }
396
397    fn to_json_value(&self) -> serde_json::Result<serde_json::Value> {
398        serde_json::to_value(self)
399    }
400}
401
402impl ResourceDefinition for ComputeCluster {
403    fn get_resource_type(&self) -> ResourceType {
404        Self::RESOURCE_TYPE
405    }
406
407    fn id(&self) -> &str {
408        &self.id
409    }
410
411    fn replace_after_failed_create_is_safe(&self) -> bool {
412        // A create that failed waiting for instances may already run some; deleting it terminates
413        // them, and in a capacity-starved zone the capacity may not come back.
414        false
415    }
416
417    fn get_dependencies(&self) -> Vec<ResourceRef> {
418        // ComputeCluster has no static dependencies.
419        // Network dependency is platform-specific:
420        // - AWS/GCP/Azure: Added by ComputeClusterMutation
421        // - Local/Kubernetes: Not needed (Docker/K8s handles networking)
422        // Platform controllers use require_dependency() at runtime to access Network state.
423        Vec::new()
424    }
425
426    fn validate_update(&self, new_config: &dyn ResourceDefinition) -> Result<()> {
427        let new_cluster = new_config
428            .as_any()
429            .downcast_ref::<ComputeCluster>()
430            .ok_or_else(|| {
431                AlienError::new(ErrorData::UnexpectedResourceType {
432                    resource_id: self.id.clone(),
433                    expected: Self::RESOURCE_TYPE,
434                    actual: new_config.get_resource_type(),
435                })
436            })?;
437
438        if self.id != new_cluster.id {
439            return Err(AlienError::new(ErrorData::InvalidResourceUpdate {
440                resource_id: self.id.clone(),
441                reason: "the 'id' field is immutable".to_string(),
442            }));
443        }
444
445        // Container CIDR is immutable once set
446        if self.container_cidr.is_some()
447            && new_cluster.container_cidr.is_some()
448            && self.container_cidr != new_cluster.container_cidr
449        {
450            return Err(AlienError::new(ErrorData::InvalidResourceUpdate {
451                resource_id: self.id.clone(),
452                reason: "the 'containerCidr' field is immutable once set".to_string(),
453            }));
454        }
455
456        // Validate capacity groups
457        for new_group in &new_cluster.capacity_groups {
458            if let Some(existing_group) = self
459                .capacity_groups
460                .iter()
461                .find(|g| g.group_id == new_group.group_id)
462            {
463                // The controller rolls the fleet onto another machine of the same architecture;
464                // the stack's images may not run on any other machine. Preflights also limit
465                // this to the installed cloud, which this check can't see.
466                if let (Some(old), Some(new)) = (
467                    existing_group.instance_type.as_deref(),
468                    new_group.instance_type.as_deref(),
469                ) {
470                    if old != new
471                        && ![Platform::Aws, Platform::Gcp, Platform::Azure]
472                            .into_iter()
473                            .any(|platform| is_same_architecture_machine(platform, old, new))
474                    {
475                        return Err(AlienError::new(ErrorData::InvalidResourceUpdate {
476                            resource_id: self.id.clone(),
477                            reason: format!(
478                                "capacity group '{}' can't change machine from '{old}' to '{new}': only a catalog machine in the same cloud and of the same CPU architecture can replace it",
479                                new_group.group_id
480                            ),
481                        }));
482                    }
483                }
484            }
485        }
486
487        Ok(())
488    }
489
490    fn as_any(&self) -> &dyn Any {
491        self
492    }
493
494    fn as_any_mut(&mut self) -> &mut dyn Any {
495        self
496    }
497
498    fn box_clone(&self) -> Box<dyn ResourceDefinition> {
499        Box::new(self.clone())
500    }
501
502    fn resource_eq(&self, other: &dyn ResourceDefinition) -> bool {
503        other.as_any().downcast_ref::<ComputeCluster>() == Some(self)
504    }
505
506    fn to_json_value(&self) -> serde_json::Result<serde_json::Value> {
507        serde_json::to_value(self)
508    }
509}
510
511#[cfg(test)]
512mod tests {
513    use super::*;
514
515    #[test]
516    fn aws_disk_sizing_preserves_rounding_minimums_and_saturation() {
517        let gib = 1024 * 1024 * 1024;
518        for (bytes, root, data) in [
519            (0, 20, 20),
520            (gib, 20, 20),
521            (20 * gib, 37, 25),
522            (20 * gib + 1, 39, 27),
523            (u64::MAX, i32::MAX, i32::MAX),
524        ] {
525            let mut group = CapacityGroup {
526                group_id: "general".into(),
527                instance_type: None,
528                profile: Some(MachineProfile {
529                    cpu: "4.0".into(),
530                    memory_bytes: 8 * gib,
531                    ephemeral_storage_bytes: bytes,
532                    architecture: None,
533                    gpu: None,
534                    data_disk: None,
535                }),
536                min_size: 1,
537                max_size: 1,
538                scale_policy: None,
539                nested_virtualization: None,
540            };
541            assert_eq!(group.aws_root_volume_size_gib(), root);
542            assert_eq!(group.aws_runtime_data_volume_size_gib(), None);
543            group.nested_virtualization = Some(true);
544            assert_eq!(group.aws_root_volume_size_gib(), root);
545            assert_eq!(group.aws_runtime_data_volume_size_gib(), Some(data));
546        }
547    }
548
549    #[test]
550    fn declared_data_disk_replaces_the_nested_virtualization_volume() {
551        let gib = 1024 * 1024 * 1024;
552        let group = |nested: Option<bool>, data_disk_bytes: u64| CapacityGroup {
553            group_id: "general".into(),
554            instance_type: None,
555            profile: Some(MachineProfile {
556                cpu: "4.0".into(),
557                memory_bytes: 8 * gib,
558                ephemeral_storage_bytes: 20 * gib,
559                architecture: None,
560                gpu: None,
561                data_disk: Some(DataDiskRequirement {
562                    size_bytes: data_disk_bytes,
563                }),
564            }),
565            min_size: 1,
566            max_size: 1,
567            scale_policy: None,
568            nested_virtualization: nested,
569        };
570        for nested in [None, Some(false), Some(true)] {
571            // Root keeps the ephemeral storage formula; the second volume is
572            // exactly the declared size, not the 25 GiB implicit volume.
573            assert_eq!(group(nested, 30 * gib).aws_root_volume_size_gib(), 37);
574            assert_eq!(
575                group(nested, 30 * gib).aws_runtime_data_volume_size_gib(),
576                Some(30)
577            );
578            assert_eq!(
579                group(nested, 30 * gib + 1).aws_runtime_data_volume_size_gib(),
580                Some(31)
581            );
582        }
583        assert_eq!(group(None, 512 * 1024 * 1024).data_disk_size_gib(), Some(1));
584    }
585
586    #[test]
587    fn test_compute_cluster_creation() {
588        let cluster = ComputeCluster::new("compute".to_string())
589            .capacity_group(CapacityGroup {
590                group_id: "general".to_string(),
591                instance_type: Some("m7g.xlarge".to_string()),
592                profile: None,
593                min_size: 1,
594                max_size: 5,
595                scale_policy: None,
596                nested_virtualization: None,
597            })
598            .build();
599
600        assert_eq!(cluster.id(), "compute");
601        assert_eq!(cluster.capacity_groups.len(), 1);
602        assert_eq!(cluster.capacity_groups[0].group_id, "general");
603        assert_eq!(cluster.container_cidr(), "10.244.0.0/16");
604    }
605
606    #[test]
607    fn test_compute_cluster_multiple_capacity_groups() {
608        let cluster = ComputeCluster::new("multi-pool".to_string())
609            .capacity_group(CapacityGroup {
610                group_id: "general".to_string(),
611                instance_type: Some("m7g.xlarge".to_string()),
612                profile: None,
613                min_size: 1,
614                max_size: 3,
615                scale_policy: None,
616                nested_virtualization: None,
617            })
618            .capacity_group(CapacityGroup {
619                group_id: "gpu".to_string(),
620                instance_type: Some("g5.xlarge".to_string()),
621                profile: Some(MachineProfile {
622                    cpu: "4.0".to_string(),
623                    memory_bytes: 17179869184,             // 16 GiB
624                    ephemeral_storage_bytes: 214748364800, // 200 GiB
625                    architecture: None,
626                    gpu: Some(GpuSpec {
627                        gpu_type: "nvidia-a10g".to_string(),
628                        count: 1,
629                    }),
630                    data_disk: None,
631                }),
632                min_size: 0,
633                max_size: 2,
634                scale_policy: None,
635                nested_virtualization: None,
636            })
637            .build();
638
639        assert_eq!(cluster.capacity_groups.len(), 2);
640        assert_eq!(cluster.capacity_groups[0].group_id, "general");
641        assert_eq!(cluster.capacity_groups[1].group_id, "gpu");
642        assert!(cluster.capacity_groups[1]
643            .profile
644            .as_ref()
645            .unwrap()
646            .gpu
647            .is_some());
648    }
649
650    #[test]
651    fn test_compute_cluster_custom_cidr() {
652        let cluster = ComputeCluster::new("custom-net".to_string())
653            .container_cidr("172.30.0.0/16".to_string())
654            .capacity_group(CapacityGroup {
655                group_id: "general".to_string(),
656                instance_type: None,
657                profile: None,
658                min_size: 1,
659                max_size: 5,
660                scale_policy: None,
661                nested_virtualization: None,
662            })
663            .build();
664
665        assert_eq!(cluster.container_cidr(), "172.30.0.0/16");
666    }
667
668    #[test]
669    fn test_compute_cluster_validate_update_immutable_id() {
670        let cluster1 = ComputeCluster::new("cluster-1".to_string())
671            .capacity_group(CapacityGroup {
672                group_id: "general".to_string(),
673                instance_type: None,
674                profile: None,
675                min_size: 1,
676                max_size: 5,
677                scale_policy: None,
678                nested_virtualization: None,
679            })
680            .build();
681
682        let cluster2 = ComputeCluster::new("cluster-2".to_string())
683            .capacity_group(CapacityGroup {
684                group_id: "general".to_string(),
685                instance_type: None,
686                profile: None,
687                min_size: 1,
688                max_size: 5,
689                scale_policy: None,
690                nested_virtualization: None,
691            })
692            .build();
693
694        let result = cluster1.validate_update(&cluster2);
695        assert!(result.is_err());
696    }
697
698    #[test]
699    fn test_compute_cluster_validate_update_scale_change() {
700        let cluster1 = ComputeCluster::new("compute".to_string())
701            .capacity_group(CapacityGroup {
702                group_id: "general".to_string(),
703                instance_type: Some("m7g.xlarge".to_string()),
704                profile: None,
705                min_size: 1,
706                max_size: 5,
707                scale_policy: None,
708                nested_virtualization: None,
709            })
710            .build();
711
712        let cluster2 = ComputeCluster::new("compute".to_string())
713            .capacity_group(CapacityGroup {
714                group_id: "general".to_string(),
715                instance_type: Some("m7g.xlarge".to_string()),
716                profile: None,
717                min_size: 2,
718                max_size: 10,
719                scale_policy: None,
720                nested_virtualization: None,
721            })
722            .build();
723
724        // Scale changes should be allowed
725        let result = cluster1.validate_update(&cluster2);
726        assert!(result.is_ok());
727    }
728
729    #[test]
730    fn machine_changes_stay_within_one_cloud_and_architecture() {
731        let cluster = |machine: &str| {
732            ComputeCluster::new("compute".to_string())
733                .capacity_group(CapacityGroup {
734                    group_id: "general".to_string(),
735                    instance_type: Some(machine.to_string()),
736                    profile: None,
737                    min_size: 1,
738                    max_size: 5,
739                    scale_policy: None,
740                    nested_virtualization: None,
741                })
742                .build()
743        };
744        for (old, new) in [
745            ("t4g.small", "t4g.medium"),
746            ("e2-medium", "n2-standard-4"),
747            ("Standard_D2s_v5", "Standard_D4s_v5"),
748        ] {
749            cluster(old)
750                .validate_update(&cluster(new))
751                .expect("same cloud and architecture");
752        }
753        for (old, new) in [
754            ("t4g.small", "m7i.large"),
755            ("t4g.small", "t4g.unknown"),
756            ("n2-standard-2", "m7i.large"),
757            ("Standard_D2s_v5", "n2-standard-4"),
758        ] {
759            let error = cluster(old)
760                .validate_update(&cluster(new))
761                .expect_err("needs setup");
762            assert_eq!(error.code, "INVALID_RESOURCE_UPDATE", "{old} -> {new}");
763        }
764    }
765
766    #[test]
767    fn node_permissions_are_inline_optional_and_roundtrip_exactly() {
768        let mut cluster = ComputeCluster::new("compute".to_string()).build();
769        let absent = serde_json::to_value(&cluster).unwrap();
770        assert!(absent.get("nodePermissions").is_none());
771        assert_eq!(
772            serde_json::from_value::<ComputeCluster>(absent).unwrap(),
773            cluster
774        );
775        cluster.node_permissions =
776            Some(PermissionProfile::new().resource("objects", ["storage/data-read"]));
777        let json = serde_json::to_value(&cluster).unwrap();
778        assert_eq!(
779            json["nodePermissions"],
780            serde_json::json!({"objects": ["storage/data-read"]})
781        );
782        assert_eq!(
783            serde_json::from_value::<ComputeCluster>(json).unwrap(),
784            cluster
785        );
786        assert_eq!(ResourceDefinition::get_permissions(&cluster), None);
787    }
788
789    #[test]
790    fn test_compute_cluster_serialization() {
791        let cluster = ComputeCluster::new("test-cluster".to_string())
792            .capacity_group(CapacityGroup {
793                group_id: "general".to_string(),
794                instance_type: Some("m7g.xlarge".to_string()),
795                profile: None,
796                min_size: 1,
797                max_size: 5,
798                scale_policy: None,
799                nested_virtualization: None,
800            })
801            .build();
802
803        let json = serde_json::to_string(&cluster).unwrap();
804        let deserialized: ComputeCluster = serde_json::from_str(&json).unwrap();
805        assert_eq!(cluster, deserialized);
806    }
807}