1use 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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
23#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
24#[serde(rename_all = "camelCase")]
25pub struct GpuSpec {
26 #[serde(rename = "type")]
28 pub gpu_type: String,
29 pub count: u32,
31}
32
33#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
39#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
40#[serde(rename_all = "camelCase")]
41pub struct MachineProfile {
42 pub cpu: String,
45 pub memory_bytes: u64,
47 pub ephemeral_storage_bytes: u64,
49 #[serde(skip_serializing_if = "Option::is_none")]
51 pub architecture: Option<Architecture>,
52 #[serde(skip_serializing_if = "Option::is_none")]
54 pub gpu: Option<GpuSpec>,
55 #[serde(default, skip_serializing_if = "Option::is_none")]
60 pub data_disk: Option<DataDiskRequirement>,
61}
62
63#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
66#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
67#[serde(rename_all = "camelCase")]
68pub struct DataDiskRequirement {
69 pub size_bytes: u64,
71}
72
73#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
75#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
76#[serde(rename_all = "camelCase")]
77pub struct ComputeChoiceRange {
78 pub min: u32,
80 pub max: u32,
82 pub default: u32,
84}
85
86impl ComputeChoiceRange {
87 pub fn contains(&self, value: u32) -> bool {
89 self.min <= value && value <= self.max
90 }
91}
92
93#[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 Fixed {
100 machines: ComputeChoiceRange,
102 },
103 Autoscale {
105 min: ComputeChoiceRange,
107 max: ComputeChoiceRange,
109 },
110}
111
112impl CapacityGroupScalePolicy {
113 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 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 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#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
162#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
163#[serde(rename_all = "camelCase")]
164pub struct CapacityGroup {
165 pub group_id: String,
167 #[serde(skip_serializing_if = "Option::is_none")]
171 pub instance_type: Option<String>,
172 #[serde(skip_serializing_if = "Option::is_none")]
174 pub profile: Option<MachineProfile>,
175 pub min_size: u32,
177 pub max_size: u32,
179 #[serde(skip_serializing_if = "Option::is_none")]
184 pub scale_policy: Option<CapacityGroupScalePolicy>,
185 #[serde(skip_serializing_if = "Option::is_none")]
191 pub nested_virtualization: Option<bool>,
192}
193
194impl CapacityGroup {
195 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 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 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 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#[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 #[builder(start_fn)]
287 pub id: String,
288
289 #[builder(field)]
292 pub capacity_groups: Vec<CapacityGroup>,
293
294 #[serde(default, skip_serializing_if = "Option::is_none")]
297 pub node_permissions: Option<PermissionProfile>,
298
299 #[serde(default, skip_serializing_if = "Option::is_none")]
301 pub node_permissions_platforms: Option<Vec<Platform>>,
302
303 #[serde(default, skip_serializing_if = "Option::is_none")]
306 pub dynamic_container_pool: Option<String>,
307
308 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
311 #[builder(default)]
312 pub selected_failure_domains: BTreeMap<String, Vec<String>>,
313
314 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
317 #[builder(default)]
318 pub failure_domain_spread: BTreeMap<String, u8>,
319
320 #[serde(skip_serializing_if = "Option::is_none")]
324 pub container_cidr: Option<String>,
325}
326
327impl ComputeCluster {
328 pub const RESOURCE_TYPE: ResourceType = ResourceType::from_static("compute-cluster");
330
331 pub fn id(&self) -> &str {
333 &self.id
334 }
335
336 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 pub fn capacity_group(mut self, group: CapacityGroup) -> Self {
345 self.capacity_groups.push(group);
346 self
347 }
348}
349
350#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
352#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
353#[serde(rename_all = "camelCase")]
354pub struct CapacityGroupStatus {
355 pub group_id: String,
357 pub current_machines: u32,
359 pub desired_machines: u32,
361 pub instance_type: String,
363}
364
365#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
367#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
368#[serde(rename_all = "camelCase")]
369pub struct ComputeClusterOutputs {
370 pub cluster_id: String,
372 pub horizon_ready: bool,
374 pub capacity_group_statuses: Vec<CapacityGroupStatus>,
376 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 false
415 }
416
417 fn get_dependencies(&self) -> Vec<ResourceRef> {
418 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 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 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 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 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, ephemeral_storage_bytes: 214748364800, 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 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}