1use crate::error::{ErrorData, Result};
11use crate::instance_catalog::Architecture;
12use crate::resource::{ResourceDefinition, ResourceOutputsDefinition, ResourceRef};
13use crate::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 = "BTreeMap::is_empty")]
232 #[builder(default)]
233 pub selected_failure_domains: BTreeMap<String, Vec<String>>,
234
235 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
238 #[builder(default)]
239 pub failure_domain_spread: BTreeMap<String, u8>,
240
241 #[serde(skip_serializing_if = "Option::is_none")]
245 pub container_cidr: Option<String>,
246}
247
248impl ComputeCluster {
249 pub const RESOURCE_TYPE: ResourceType = ResourceType::from_static("compute-cluster");
251
252 pub fn id(&self) -> &str {
254 &self.id
255 }
256
257 pub fn container_cidr(&self) -> &str {
259 self.container_cidr.as_deref().unwrap_or("10.244.0.0/16")
260 }
261}
262
263impl<S: compute_cluster_builder::State> ComputeClusterBuilder<S> {
264 pub fn capacity_group(mut self, group: CapacityGroup) -> Self {
266 self.capacity_groups.push(group);
267 self
268 }
269}
270
271#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
273#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
274#[serde(rename_all = "camelCase")]
275pub struct CapacityGroupStatus {
276 pub group_id: String,
278 pub current_machines: u32,
280 pub desired_machines: u32,
282 pub instance_type: String,
284}
285
286#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
288#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
289#[serde(rename_all = "camelCase")]
290pub struct ComputeClusterOutputs {
291 pub cluster_id: String,
293 pub horizon_ready: bool,
295 pub capacity_group_statuses: Vec<CapacityGroupStatus>,
297 pub total_machines: u32,
299}
300
301impl ResourceOutputsDefinition for ComputeClusterOutputs {
302 fn get_resource_type(&self) -> ResourceType {
303 ComputeCluster::RESOURCE_TYPE.clone()
304 }
305
306 fn as_any(&self) -> &dyn Any {
307 self
308 }
309
310 fn box_clone(&self) -> Box<dyn ResourceOutputsDefinition> {
311 Box::new(self.clone())
312 }
313
314 fn outputs_eq(&self, other: &dyn ResourceOutputsDefinition) -> bool {
315 other.as_any().downcast_ref::<ComputeClusterOutputs>() == Some(self)
316 }
317
318 fn to_json_value(&self) -> serde_json::Result<serde_json::Value> {
319 serde_json::to_value(self)
320 }
321}
322
323impl ResourceDefinition for ComputeCluster {
324 fn get_resource_type(&self) -> ResourceType {
325 Self::RESOURCE_TYPE
326 }
327
328 fn id(&self) -> &str {
329 &self.id
330 }
331
332 fn get_dependencies(&self) -> Vec<ResourceRef> {
333 Vec::new()
339 }
340
341 fn validate_update(&self, new_config: &dyn ResourceDefinition) -> Result<()> {
342 let new_cluster = new_config
343 .as_any()
344 .downcast_ref::<ComputeCluster>()
345 .ok_or_else(|| {
346 AlienError::new(ErrorData::UnexpectedResourceType {
347 resource_id: self.id.clone(),
348 expected: Self::RESOURCE_TYPE,
349 actual: new_config.get_resource_type(),
350 })
351 })?;
352
353 if self.id != new_cluster.id {
354 return Err(AlienError::new(ErrorData::InvalidResourceUpdate {
355 resource_id: self.id.clone(),
356 reason: "the 'id' field is immutable".to_string(),
357 }));
358 }
359
360 if self.container_cidr.is_some()
362 && new_cluster.container_cidr.is_some()
363 && self.container_cidr != new_cluster.container_cidr
364 {
365 return Err(AlienError::new(ErrorData::InvalidResourceUpdate {
366 resource_id: self.id.clone(),
367 reason: "the 'containerCidr' field is immutable once set".to_string(),
368 }));
369 }
370
371 for new_group in &new_cluster.capacity_groups {
373 if let Some(existing_group) = self
374 .capacity_groups
375 .iter()
376 .find(|g| g.group_id == new_group.group_id)
377 {
378 if existing_group.instance_type.is_some()
380 && new_group.instance_type.is_some()
381 && existing_group.instance_type != new_group.instance_type
382 {
383 return Err(AlienError::new(ErrorData::InvalidResourceUpdate {
384 resource_id: self.id.clone(),
385 reason: format!(
386 "instance type for capacity group '{}' is immutable",
387 new_group.group_id
388 ),
389 }));
390 }
391 }
392 }
393
394 Ok(())
395 }
396
397 fn as_any(&self) -> &dyn Any {
398 self
399 }
400
401 fn as_any_mut(&mut self) -> &mut dyn Any {
402 self
403 }
404
405 fn box_clone(&self) -> Box<dyn ResourceDefinition> {
406 Box::new(self.clone())
407 }
408
409 fn resource_eq(&self, other: &dyn ResourceDefinition) -> bool {
410 other.as_any().downcast_ref::<ComputeCluster>() == Some(self)
411 }
412
413 fn to_json_value(&self) -> serde_json::Result<serde_json::Value> {
414 serde_json::to_value(self)
415 }
416}
417
418#[cfg(test)]
419mod tests {
420 use super::*;
421
422 #[test]
423 fn test_compute_cluster_creation() {
424 let cluster = ComputeCluster::new("compute".to_string())
425 .capacity_group(CapacityGroup {
426 group_id: "general".to_string(),
427 instance_type: Some("m7g.xlarge".to_string()),
428 profile: None,
429 min_size: 1,
430 max_size: 5,
431 scale_policy: None,
432 nested_virtualization: None,
433 })
434 .build();
435
436 assert_eq!(cluster.id(), "compute");
437 assert_eq!(cluster.capacity_groups.len(), 1);
438 assert_eq!(cluster.capacity_groups[0].group_id, "general");
439 assert_eq!(cluster.container_cidr(), "10.244.0.0/16");
440 }
441
442 #[test]
443 fn test_compute_cluster_multiple_capacity_groups() {
444 let cluster = ComputeCluster::new("multi-pool".to_string())
445 .capacity_group(CapacityGroup {
446 group_id: "general".to_string(),
447 instance_type: Some("m7g.xlarge".to_string()),
448 profile: None,
449 min_size: 1,
450 max_size: 3,
451 scale_policy: None,
452 nested_virtualization: None,
453 })
454 .capacity_group(CapacityGroup {
455 group_id: "gpu".to_string(),
456 instance_type: Some("g5.xlarge".to_string()),
457 profile: Some(MachineProfile {
458 cpu: "4.0".to_string(),
459 memory_bytes: 17179869184, ephemeral_storage_bytes: 214748364800, architecture: None,
462 gpu: Some(GpuSpec {
463 gpu_type: "nvidia-a10g".to_string(),
464 count: 1,
465 }),
466 }),
467 min_size: 0,
468 max_size: 2,
469 scale_policy: None,
470 nested_virtualization: None,
471 })
472 .build();
473
474 assert_eq!(cluster.capacity_groups.len(), 2);
475 assert_eq!(cluster.capacity_groups[0].group_id, "general");
476 assert_eq!(cluster.capacity_groups[1].group_id, "gpu");
477 assert!(cluster.capacity_groups[1]
478 .profile
479 .as_ref()
480 .unwrap()
481 .gpu
482 .is_some());
483 }
484
485 #[test]
486 fn test_compute_cluster_custom_cidr() {
487 let cluster = ComputeCluster::new("custom-net".to_string())
488 .container_cidr("172.30.0.0/16".to_string())
489 .capacity_group(CapacityGroup {
490 group_id: "general".to_string(),
491 instance_type: None,
492 profile: None,
493 min_size: 1,
494 max_size: 5,
495 scale_policy: None,
496 nested_virtualization: None,
497 })
498 .build();
499
500 assert_eq!(cluster.container_cidr(), "172.30.0.0/16");
501 }
502
503 #[test]
504 fn test_compute_cluster_validate_update_immutable_id() {
505 let cluster1 = ComputeCluster::new("cluster-1".to_string())
506 .capacity_group(CapacityGroup {
507 group_id: "general".to_string(),
508 instance_type: None,
509 profile: None,
510 min_size: 1,
511 max_size: 5,
512 scale_policy: None,
513 nested_virtualization: None,
514 })
515 .build();
516
517 let cluster2 = ComputeCluster::new("cluster-2".to_string())
518 .capacity_group(CapacityGroup {
519 group_id: "general".to_string(),
520 instance_type: None,
521 profile: None,
522 min_size: 1,
523 max_size: 5,
524 scale_policy: None,
525 nested_virtualization: None,
526 })
527 .build();
528
529 let result = cluster1.validate_update(&cluster2);
530 assert!(result.is_err());
531 }
532
533 #[test]
534 fn test_compute_cluster_validate_update_scale_change() {
535 let cluster1 = ComputeCluster::new("compute".to_string())
536 .capacity_group(CapacityGroup {
537 group_id: "general".to_string(),
538 instance_type: Some("m7g.xlarge".to_string()),
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 cluster2 = ComputeCluster::new("compute".to_string())
548 .capacity_group(CapacityGroup {
549 group_id: "general".to_string(),
550 instance_type: Some("m7g.xlarge".to_string()),
551 profile: None,
552 min_size: 2,
553 max_size: 10,
554 scale_policy: None,
555 nested_virtualization: None,
556 })
557 .build();
558
559 let result = cluster1.validate_update(&cluster2);
561 assert!(result.is_ok());
562 }
563
564 #[test]
565 fn test_compute_cluster_serialization() {
566 let cluster = ComputeCluster::new("test-cluster".to_string())
567 .capacity_group(CapacityGroup {
568 group_id: "general".to_string(),
569 instance_type: Some("m7g.xlarge".to_string()),
570 profile: None,
571 min_size: 1,
572 max_size: 5,
573 scale_policy: None,
574 nested_virtualization: None,
575 })
576 .build();
577
578 let json = serde_json::to_string(&cluster).unwrap();
579 let deserialized: ComputeCluster = serde_json::from_str(&json).unwrap();
580 assert_eq!(cluster, deserialized);
581 }
582}