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 replace_after_failed_create_is_safe(&self) -> bool {
347 false
350 }
351
352 fn get_dependencies(&self) -> Vec<ResourceRef> {
353 Vec::new()
359 }
360
361 fn validate_update(&self, new_config: &dyn ResourceDefinition) -> Result<()> {
362 let new_cluster = new_config
363 .as_any()
364 .downcast_ref::<ComputeCluster>()
365 .ok_or_else(|| {
366 AlienError::new(ErrorData::UnexpectedResourceType {
367 resource_id: self.id.clone(),
368 expected: Self::RESOURCE_TYPE,
369 actual: new_config.get_resource_type(),
370 })
371 })?;
372
373 if self.id != new_cluster.id {
374 return Err(AlienError::new(ErrorData::InvalidResourceUpdate {
375 resource_id: self.id.clone(),
376 reason: "the 'id' field is immutable".to_string(),
377 }));
378 }
379
380 if self.container_cidr.is_some()
382 && new_cluster.container_cidr.is_some()
383 && self.container_cidr != new_cluster.container_cidr
384 {
385 return Err(AlienError::new(ErrorData::InvalidResourceUpdate {
386 resource_id: self.id.clone(),
387 reason: "the 'containerCidr' field is immutable once set".to_string(),
388 }));
389 }
390
391 for new_group in &new_cluster.capacity_groups {
393 if let Some(existing_group) = self
394 .capacity_groups
395 .iter()
396 .find(|g| g.group_id == new_group.group_id)
397 {
398 if let (Some(old), Some(new)) = (
402 existing_group.instance_type.as_deref(),
403 new_group.instance_type.as_deref(),
404 ) {
405 if old != new && !is_same_architecture_aws_machine(old, new) {
406 return Err(AlienError::new(ErrorData::InvalidResourceUpdate {
407 resource_id: self.id.clone(),
408 reason: format!(
409 "capacity group '{}' can't change machine from '{old}' to '{new}': only an AWS machine of the same CPU architecture can replace it",
410 new_group.group_id
411 ),
412 }));
413 }
414 }
415 }
416 }
417
418 Ok(())
419 }
420
421 fn as_any(&self) -> &dyn Any {
422 self
423 }
424
425 fn as_any_mut(&mut self) -> &mut dyn Any {
426 self
427 }
428
429 fn box_clone(&self) -> Box<dyn ResourceDefinition> {
430 Box::new(self.clone())
431 }
432
433 fn resource_eq(&self, other: &dyn ResourceDefinition) -> bool {
434 other.as_any().downcast_ref::<ComputeCluster>() == Some(self)
435 }
436
437 fn to_json_value(&self) -> serde_json::Result<serde_json::Value> {
438 serde_json::to_value(self)
439 }
440}
441
442#[cfg(test)]
443mod tests {
444 use super::*;
445
446 #[test]
447 fn test_compute_cluster_creation() {
448 let cluster = ComputeCluster::new("compute".to_string())
449 .capacity_group(CapacityGroup {
450 group_id: "general".to_string(),
451 instance_type: Some("m7g.xlarge".to_string()),
452 profile: None,
453 min_size: 1,
454 max_size: 5,
455 scale_policy: None,
456 nested_virtualization: None,
457 })
458 .build();
459
460 assert_eq!(cluster.id(), "compute");
461 assert_eq!(cluster.capacity_groups.len(), 1);
462 assert_eq!(cluster.capacity_groups[0].group_id, "general");
463 assert_eq!(cluster.container_cidr(), "10.244.0.0/16");
464 }
465
466 #[test]
467 fn test_compute_cluster_multiple_capacity_groups() {
468 let cluster = ComputeCluster::new("multi-pool".to_string())
469 .capacity_group(CapacityGroup {
470 group_id: "general".to_string(),
471 instance_type: Some("m7g.xlarge".to_string()),
472 profile: None,
473 min_size: 1,
474 max_size: 3,
475 scale_policy: None,
476 nested_virtualization: None,
477 })
478 .capacity_group(CapacityGroup {
479 group_id: "gpu".to_string(),
480 instance_type: Some("g5.xlarge".to_string()),
481 profile: Some(MachineProfile {
482 cpu: "4.0".to_string(),
483 memory_bytes: 17179869184, ephemeral_storage_bytes: 214748364800, architecture: None,
486 gpu: Some(GpuSpec {
487 gpu_type: "nvidia-a10g".to_string(),
488 count: 1,
489 }),
490 }),
491 min_size: 0,
492 max_size: 2,
493 scale_policy: None,
494 nested_virtualization: None,
495 })
496 .build();
497
498 assert_eq!(cluster.capacity_groups.len(), 2);
499 assert_eq!(cluster.capacity_groups[0].group_id, "general");
500 assert_eq!(cluster.capacity_groups[1].group_id, "gpu");
501 assert!(cluster.capacity_groups[1]
502 .profile
503 .as_ref()
504 .unwrap()
505 .gpu
506 .is_some());
507 }
508
509 #[test]
510 fn test_compute_cluster_custom_cidr() {
511 let cluster = ComputeCluster::new("custom-net".to_string())
512 .container_cidr("172.30.0.0/16".to_string())
513 .capacity_group(CapacityGroup {
514 group_id: "general".to_string(),
515 instance_type: None,
516 profile: None,
517 min_size: 1,
518 max_size: 5,
519 scale_policy: None,
520 nested_virtualization: None,
521 })
522 .build();
523
524 assert_eq!(cluster.container_cidr(), "172.30.0.0/16");
525 }
526
527 #[test]
528 fn test_compute_cluster_validate_update_immutable_id() {
529 let cluster1 = ComputeCluster::new("cluster-1".to_string())
530 .capacity_group(CapacityGroup {
531 group_id: "general".to_string(),
532 instance_type: None,
533 profile: None,
534 min_size: 1,
535 max_size: 5,
536 scale_policy: None,
537 nested_virtualization: None,
538 })
539 .build();
540
541 let cluster2 = ComputeCluster::new("cluster-2".to_string())
542 .capacity_group(CapacityGroup {
543 group_id: "general".to_string(),
544 instance_type: None,
545 profile: None,
546 min_size: 1,
547 max_size: 5,
548 scale_policy: None,
549 nested_virtualization: None,
550 })
551 .build();
552
553 let result = cluster1.validate_update(&cluster2);
554 assert!(result.is_err());
555 }
556
557 #[test]
558 fn test_compute_cluster_validate_update_scale_change() {
559 let cluster1 = ComputeCluster::new("compute".to_string())
560 .capacity_group(CapacityGroup {
561 group_id: "general".to_string(),
562 instance_type: Some("m7g.xlarge".to_string()),
563 profile: None,
564 min_size: 1,
565 max_size: 5,
566 scale_policy: None,
567 nested_virtualization: None,
568 })
569 .build();
570
571 let cluster2 = ComputeCluster::new("compute".to_string())
572 .capacity_group(CapacityGroup {
573 group_id: "general".to_string(),
574 instance_type: Some("m7g.xlarge".to_string()),
575 profile: None,
576 min_size: 2,
577 max_size: 10,
578 scale_policy: None,
579 nested_virtualization: None,
580 })
581 .build();
582
583 let result = cluster1.validate_update(&cluster2);
585 assert!(result.is_ok());
586 }
587
588 #[test]
589 fn machine_changes_stay_within_one_aws_architecture() {
590 let cluster = |machine: &str| {
591 ComputeCluster::new("compute".to_string())
592 .capacity_group(CapacityGroup {
593 group_id: "general".to_string(),
594 instance_type: Some(machine.to_string()),
595 profile: None,
596 min_size: 1,
597 max_size: 5,
598 scale_policy: None,
599 nested_virtualization: None,
600 })
601 .build()
602 };
603 cluster("t4g.small")
604 .validate_update(&cluster("t4g.medium"))
605 .expect("arm64 to arm64");
606 for (old, new) in [
607 ("t4g.small", "m7i.large"),
608 ("t4g.small", "t4g.unknown"),
609 ("n2-standard-2", "n2-standard-4"),
610 ] {
611 let error = cluster(old)
612 .validate_update(&cluster(new))
613 .expect_err("needs setup");
614 assert_eq!(error.code, "INVALID_RESOURCE_UPDATE", "{old} -> {new}");
615 }
616 }
617
618 #[test]
619 fn node_permissions_are_inline_optional_and_roundtrip_exactly() {
620 let mut cluster = ComputeCluster::new("compute".to_string()).build();
621 let absent = serde_json::to_value(&cluster).unwrap();
622 assert!(absent.get("nodePermissions").is_none());
623 assert_eq!(
624 serde_json::from_value::<ComputeCluster>(absent).unwrap(),
625 cluster
626 );
627 cluster.node_permissions =
628 Some(PermissionProfile::new().resource("objects", ["storage/data-read"]));
629 let json = serde_json::to_value(&cluster).unwrap();
630 assert_eq!(
631 json["nodePermissions"],
632 serde_json::json!({"objects": ["storage/data-read"]})
633 );
634 assert_eq!(
635 serde_json::from_value::<ComputeCluster>(json).unwrap(),
636 cluster
637 );
638 assert_eq!(ResourceDefinition::get_permissions(&cluster), None);
639 }
640
641 #[test]
642 fn test_compute_cluster_serialization() {
643 let cluster = ComputeCluster::new("test-cluster".to_string())
644 .capacity_group(CapacityGroup {
645 group_id: "general".to_string(),
646 instance_type: Some("m7g.xlarge".to_string()),
647 profile: None,
648 min_size: 1,
649 max_size: 5,
650 scale_policy: None,
651 nested_virtualization: None,
652 })
653 .build();
654
655 let json = serde_json::to_string(&cluster).unwrap();
656 let deserialized: ComputeCluster = serde_json::from_str(&json).unwrap();
657 assert_eq!(cluster, deserialized);
658 }
659}