1use crate::error::{ErrorData, Result};
11use crate::instance_catalog::{is_same_architecture_aws_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}
56
57#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
59#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
60#[serde(rename_all = "camelCase")]
61pub struct ComputeChoiceRange {
62 pub min: u32,
64 pub max: u32,
66 pub default: u32,
68}
69
70impl ComputeChoiceRange {
71 pub fn contains(&self, value: u32) -> bool {
73 self.min <= value && value <= self.max
74 }
75}
76
77#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
79#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
80#[serde(rename_all = "camelCase", tag = "type")]
81pub enum CapacityGroupScalePolicy {
82 Fixed {
84 machines: ComputeChoiceRange,
86 },
87 Autoscale {
89 min: ComputeChoiceRange,
91 max: ComputeChoiceRange,
93 },
94}
95
96impl CapacityGroupScalePolicy {
97 pub fn from_selected_bounds(min_size: u32, max_size: u32) -> Self {
99 if min_size == max_size {
100 Self::Fixed {
101 machines: ComputeChoiceRange {
102 min: min_size,
103 max: max_size,
104 default: min_size,
105 },
106 }
107 } else {
108 Self::Autoscale {
109 min: ComputeChoiceRange {
110 min: min_size,
111 max: min_size,
112 default: min_size,
113 },
114 max: ComputeChoiceRange {
115 min: max_size,
116 max: max_size,
117 default: max_size,
118 },
119 }
120 }
121 }
122
123 pub fn default_min_size(&self) -> u32 {
125 match self {
126 Self::Fixed { machines } => machines.default,
127 Self::Autoscale { min, .. } => min.default,
128 }
129 }
130
131 pub fn default_max_size(&self) -> u32 {
133 match self {
134 Self::Fixed { machines } => machines.default,
135 Self::Autoscale { max, .. } => max.default,
136 }
137 }
138}
139
140#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
146#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
147#[serde(rename_all = "camelCase")]
148pub struct CapacityGroup {
149 pub group_id: String,
151 #[serde(skip_serializing_if = "Option::is_none")]
155 pub instance_type: Option<String>,
156 #[serde(skip_serializing_if = "Option::is_none")]
158 pub profile: Option<MachineProfile>,
159 pub min_size: u32,
161 pub max_size: u32,
163 #[serde(skip_serializing_if = "Option::is_none")]
168 pub scale_policy: Option<CapacityGroupScalePolicy>,
169 #[serde(skip_serializing_if = "Option::is_none")]
175 pub nested_virtualization: Option<bool>,
176}
177
178#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Builder)]
215#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
216#[serde(rename_all = "camelCase", deny_unknown_fields)]
217#[builder(start_fn = new)]
218pub struct ComputeCluster {
219 #[builder(start_fn)]
222 pub id: String,
223
224 #[builder(field)]
227 pub capacity_groups: Vec<CapacityGroup>,
228
229 #[serde(default, skip_serializing_if = "Option::is_none")]
232 pub node_permissions: Option<PermissionProfile>,
233
234 #[serde(default, skip_serializing_if = "Option::is_none")]
236 pub node_permissions_platforms: Option<Vec<Platform>>,
237
238 #[serde(default, skip_serializing_if = "Option::is_none")]
241 pub dynamic_container_pool: Option<String>,
242
243 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
246 #[builder(default)]
247 pub selected_failure_domains: BTreeMap<String, Vec<String>>,
248
249 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
252 #[builder(default)]
253 pub failure_domain_spread: BTreeMap<String, u8>,
254
255 #[serde(skip_serializing_if = "Option::is_none")]
259 pub container_cidr: Option<String>,
260}
261
262impl ComputeCluster {
263 pub const RESOURCE_TYPE: ResourceType = ResourceType::from_static("compute-cluster");
265
266 pub fn id(&self) -> &str {
268 &self.id
269 }
270
271 pub fn container_cidr(&self) -> &str {
273 self.container_cidr.as_deref().unwrap_or("10.244.0.0/16")
274 }
275}
276
277impl<S: compute_cluster_builder::State> ComputeClusterBuilder<S> {
278 pub fn capacity_group(mut self, group: CapacityGroup) -> Self {
280 self.capacity_groups.push(group);
281 self
282 }
283}
284
285#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
287#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
288#[serde(rename_all = "camelCase")]
289pub struct CapacityGroupStatus {
290 pub group_id: String,
292 pub current_machines: u32,
294 pub desired_machines: u32,
296 pub instance_type: String,
298}
299
300#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
302#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
303#[serde(rename_all = "camelCase")]
304pub struct ComputeClusterOutputs {
305 pub cluster_id: String,
307 pub horizon_ready: bool,
309 pub capacity_group_statuses: Vec<CapacityGroupStatus>,
311 pub total_machines: u32,
313}
314
315impl ResourceOutputsDefinition for ComputeClusterOutputs {
316 fn get_resource_type(&self) -> ResourceType {
317 ComputeCluster::RESOURCE_TYPE.clone()
318 }
319
320 fn as_any(&self) -> &dyn Any {
321 self
322 }
323
324 fn box_clone(&self) -> Box<dyn ResourceOutputsDefinition> {
325 Box::new(self.clone())
326 }
327
328 fn outputs_eq(&self, other: &dyn ResourceOutputsDefinition) -> bool {
329 other.as_any().downcast_ref::<ComputeClusterOutputs>() == Some(self)
330 }
331
332 fn to_json_value(&self) -> serde_json::Result<serde_json::Value> {
333 serde_json::to_value(self)
334 }
335}
336
337impl ResourceDefinition for ComputeCluster {
338 fn get_resource_type(&self) -> ResourceType {
339 Self::RESOURCE_TYPE
340 }
341
342 fn id(&self) -> &str {
343 &self.id
344 }
345
346 fn get_dependencies(&self) -> Vec<ResourceRef> {
347 Vec::new()
353 }
354
355 fn validate_update(&self, new_config: &dyn ResourceDefinition) -> Result<()> {
356 let new_cluster = new_config
357 .as_any()
358 .downcast_ref::<ComputeCluster>()
359 .ok_or_else(|| {
360 AlienError::new(ErrorData::UnexpectedResourceType {
361 resource_id: self.id.clone(),
362 expected: Self::RESOURCE_TYPE,
363 actual: new_config.get_resource_type(),
364 })
365 })?;
366
367 if self.id != new_cluster.id {
368 return Err(AlienError::new(ErrorData::InvalidResourceUpdate {
369 resource_id: self.id.clone(),
370 reason: "the 'id' field is immutable".to_string(),
371 }));
372 }
373
374 if self.container_cidr.is_some()
376 && new_cluster.container_cidr.is_some()
377 && self.container_cidr != new_cluster.container_cidr
378 {
379 return Err(AlienError::new(ErrorData::InvalidResourceUpdate {
380 resource_id: self.id.clone(),
381 reason: "the 'containerCidr' field is immutable once set".to_string(),
382 }));
383 }
384
385 for new_group in &new_cluster.capacity_groups {
387 if let Some(existing_group) = self
388 .capacity_groups
389 .iter()
390 .find(|g| g.group_id == new_group.group_id)
391 {
392 if let (Some(old), Some(new)) = (
396 existing_group.instance_type.as_deref(),
397 new_group.instance_type.as_deref(),
398 ) {
399 if old != new && !is_same_architecture_aws_machine(old, new) {
400 return Err(AlienError::new(ErrorData::InvalidResourceUpdate {
401 resource_id: self.id.clone(),
402 reason: format!(
403 "capacity group '{}' can't change machine from '{old}' to '{new}': only an AWS machine of the same CPU architecture can replace it",
404 new_group.group_id
405 ),
406 }));
407 }
408 }
409 }
410 }
411
412 Ok(())
413 }
414
415 fn as_any(&self) -> &dyn Any {
416 self
417 }
418
419 fn as_any_mut(&mut self) -> &mut dyn Any {
420 self
421 }
422
423 fn box_clone(&self) -> Box<dyn ResourceDefinition> {
424 Box::new(self.clone())
425 }
426
427 fn resource_eq(&self, other: &dyn ResourceDefinition) -> bool {
428 other.as_any().downcast_ref::<ComputeCluster>() == Some(self)
429 }
430
431 fn to_json_value(&self) -> serde_json::Result<serde_json::Value> {
432 serde_json::to_value(self)
433 }
434}
435
436#[cfg(test)]
437mod tests {
438 use super::*;
439
440 #[test]
441 fn test_compute_cluster_creation() {
442 let cluster = ComputeCluster::new("compute".to_string())
443 .capacity_group(CapacityGroup {
444 group_id: "general".to_string(),
445 instance_type: Some("m7g.xlarge".to_string()),
446 profile: None,
447 min_size: 1,
448 max_size: 5,
449 scale_policy: None,
450 nested_virtualization: None,
451 })
452 .build();
453
454 assert_eq!(cluster.id(), "compute");
455 assert_eq!(cluster.capacity_groups.len(), 1);
456 assert_eq!(cluster.capacity_groups[0].group_id, "general");
457 assert_eq!(cluster.container_cidr(), "10.244.0.0/16");
458 }
459
460 #[test]
461 fn test_compute_cluster_multiple_capacity_groups() {
462 let cluster = ComputeCluster::new("multi-pool".to_string())
463 .capacity_group(CapacityGroup {
464 group_id: "general".to_string(),
465 instance_type: Some("m7g.xlarge".to_string()),
466 profile: None,
467 min_size: 1,
468 max_size: 3,
469 scale_policy: None,
470 nested_virtualization: None,
471 })
472 .capacity_group(CapacityGroup {
473 group_id: "gpu".to_string(),
474 instance_type: Some("g5.xlarge".to_string()),
475 profile: Some(MachineProfile {
476 cpu: "4.0".to_string(),
477 memory_bytes: 17179869184, ephemeral_storage_bytes: 214748364800, architecture: None,
480 gpu: Some(GpuSpec {
481 gpu_type: "nvidia-a10g".to_string(),
482 count: 1,
483 }),
484 }),
485 min_size: 0,
486 max_size: 2,
487 scale_policy: None,
488 nested_virtualization: None,
489 })
490 .build();
491
492 assert_eq!(cluster.capacity_groups.len(), 2);
493 assert_eq!(cluster.capacity_groups[0].group_id, "general");
494 assert_eq!(cluster.capacity_groups[1].group_id, "gpu");
495 assert!(cluster.capacity_groups[1]
496 .profile
497 .as_ref()
498 .unwrap()
499 .gpu
500 .is_some());
501 }
502
503 #[test]
504 fn test_compute_cluster_custom_cidr() {
505 let cluster = ComputeCluster::new("custom-net".to_string())
506 .container_cidr("172.30.0.0/16".to_string())
507 .capacity_group(CapacityGroup {
508 group_id: "general".to_string(),
509 instance_type: None,
510 profile: None,
511 min_size: 1,
512 max_size: 5,
513 scale_policy: None,
514 nested_virtualization: None,
515 })
516 .build();
517
518 assert_eq!(cluster.container_cidr(), "172.30.0.0/16");
519 }
520
521 #[test]
522 fn test_compute_cluster_validate_update_immutable_id() {
523 let cluster1 = ComputeCluster::new("cluster-1".to_string())
524 .capacity_group(CapacityGroup {
525 group_id: "general".to_string(),
526 instance_type: None,
527 profile: None,
528 min_size: 1,
529 max_size: 5,
530 scale_policy: None,
531 nested_virtualization: None,
532 })
533 .build();
534
535 let cluster2 = ComputeCluster::new("cluster-2".to_string())
536 .capacity_group(CapacityGroup {
537 group_id: "general".to_string(),
538 instance_type: None,
539 profile: None,
540 min_size: 1,
541 max_size: 5,
542 scale_policy: None,
543 nested_virtualization: None,
544 })
545 .build();
546
547 let result = cluster1.validate_update(&cluster2);
548 assert!(result.is_err());
549 }
550
551 #[test]
552 fn test_compute_cluster_validate_update_scale_change() {
553 let cluster1 = ComputeCluster::new("compute".to_string())
554 .capacity_group(CapacityGroup {
555 group_id: "general".to_string(),
556 instance_type: Some("m7g.xlarge".to_string()),
557 profile: None,
558 min_size: 1,
559 max_size: 5,
560 scale_policy: None,
561 nested_virtualization: None,
562 })
563 .build();
564
565 let cluster2 = ComputeCluster::new("compute".to_string())
566 .capacity_group(CapacityGroup {
567 group_id: "general".to_string(),
568 instance_type: Some("m7g.xlarge".to_string()),
569 profile: None,
570 min_size: 2,
571 max_size: 10,
572 scale_policy: None,
573 nested_virtualization: None,
574 })
575 .build();
576
577 let result = cluster1.validate_update(&cluster2);
579 assert!(result.is_ok());
580 }
581
582 #[test]
583 fn machine_changes_stay_within_one_aws_architecture() {
584 let cluster = |machine: &str| {
585 ComputeCluster::new("compute".to_string())
586 .capacity_group(CapacityGroup {
587 group_id: "general".to_string(),
588 instance_type: Some(machine.to_string()),
589 profile: None,
590 min_size: 1,
591 max_size: 5,
592 scale_policy: None,
593 nested_virtualization: None,
594 })
595 .build()
596 };
597 cluster("t4g.small")
598 .validate_update(&cluster("t4g.medium"))
599 .expect("arm64 to arm64");
600 for (old, new) in [
601 ("t4g.small", "m7i.large"),
602 ("t4g.small", "t4g.unknown"),
603 ("n2-standard-2", "n2-standard-4"),
604 ] {
605 let error = cluster(old)
606 .validate_update(&cluster(new))
607 .expect_err("needs setup");
608 assert_eq!(error.code, "INVALID_RESOURCE_UPDATE", "{old} -> {new}");
609 }
610 }
611
612 #[test]
613 fn node_permissions_are_inline_optional_and_roundtrip_exactly() {
614 let mut cluster = ComputeCluster::new("compute".to_string()).build();
615 let absent = serde_json::to_value(&cluster).unwrap();
616 assert!(absent.get("nodePermissions").is_none());
617 assert_eq!(
618 serde_json::from_value::<ComputeCluster>(absent).unwrap(),
619 cluster
620 );
621 cluster.node_permissions =
622 Some(PermissionProfile::new().resource("objects", ["storage/data-read"]));
623 let json = serde_json::to_value(&cluster).unwrap();
624 assert_eq!(
625 json["nodePermissions"],
626 serde_json::json!({"objects": ["storage/data-read"]})
627 );
628 assert_eq!(
629 serde_json::from_value::<ComputeCluster>(json).unwrap(),
630 cluster
631 );
632 assert_eq!(ResourceDefinition::get_permissions(&cluster), None);
633 }
634
635 #[test]
636 fn test_compute_cluster_serialization() {
637 let cluster = ComputeCluster::new("test-cluster".to_string())
638 .capacity_group(CapacityGroup {
639 group_id: "general".to_string(),
640 instance_type: Some("m7g.xlarge".to_string()),
641 profile: None,
642 min_size: 1,
643 max_size: 5,
644 scale_policy: None,
645 nested_virtualization: None,
646 })
647 .build();
648
649 let json = serde_json::to_string(&cluster).unwrap();
650 let deserialized: ComputeCluster = serde_json::from_str(&json).unwrap();
651 assert_eq!(cluster, deserialized);
652 }
653}