1use crate::error::{ErrorData, Result};
15use crate::resource::{ResourceDefinition, ResourceOutputsDefinition, ResourceRef, ResourceType};
16use crate::resources::{ComputeCluster, ToolchainConfig};
17use crate::LoadBalancerEndpoint;
18use alien_error::AlienError;
19use bon::Builder;
20use serde::{Deserialize, Serialize};
21use std::any::Any;
22use std::collections::HashMap;
23use std::fmt::Debug;
24
25#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
27#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
28#[serde(rename_all = "camelCase", tag = "type")]
29pub enum ContainerCode {
30 #[serde(rename_all = "camelCase")]
32 Image {
33 image: String,
35 },
36 #[serde(rename_all = "camelCase")]
38 Source {
39 src: String,
41 toolchain: ToolchainConfig,
43 },
44}
45
46#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
48#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
49#[serde(rename_all = "camelCase")]
50pub struct ResourceSpec {
51 pub min: String,
53 pub desired: String,
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 ContainerGpuSpec {
62 #[serde(rename = "type")]
64 pub gpu_type: String,
65 pub count: u32,
67}
68
69#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
71#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
72#[serde(rename_all = "camelCase")]
73pub struct PersistentStorage {
74 pub size: String,
76 pub mount_path: String,
78}
79
80#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
82#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
83#[serde(rename_all = "camelCase")]
84pub struct ContainerAutoscaling {
85 pub min: u32,
87 pub desired: u32,
89 pub max: u32,
91 #[serde(skip_serializing_if = "Option::is_none")]
93 pub target_cpu_percent: Option<f64>,
94 #[serde(skip_serializing_if = "Option::is_none")]
96 pub target_memory_percent: Option<f64>,
97 #[serde(skip_serializing_if = "Option::is_none")]
99 pub target_http_in_flight_per_replica: Option<u32>,
100 #[serde(skip_serializing_if = "Option::is_none")]
102 pub max_http_p95_latency_ms: Option<f64>,
103}
104
105#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
107#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
108#[serde(rename_all = "camelCase")]
109pub struct HealthCheck {
110 #[serde(default = "default_health_path")]
112 pub path: String,
113 #[serde(skip_serializing_if = "Option::is_none")]
115 pub port: Option<u16>,
116 #[serde(default = "default_health_method")]
118 pub method: String,
119 #[serde(default = "default_timeout_seconds")]
121 pub timeout_seconds: u32,
122 #[serde(default = "default_failure_threshold")]
124 pub failure_threshold: u32,
125}
126
127fn default_health_path() -> String {
128 "/health".to_string()
129}
130
131fn default_health_method() -> String {
132 "GET".to_string()
133}
134
135fn default_timeout_seconds() -> u32 {
136 1
137}
138
139fn default_failure_threshold() -> u32 {
140 3
141}
142
143#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
145#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
146#[serde(rename_all = "lowercase")]
147pub enum ExposeProtocol {
148 Http,
150 Tcp,
152}
153
154#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
156#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
157#[serde(rename_all = "camelCase")]
158pub struct ContainerPort {
159 pub port: u16,
161 #[serde(skip_serializing_if = "Option::is_none")]
163 pub expose: Option<ExposeProtocol>,
164}
165
166#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Builder)]
199#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
200#[serde(rename_all = "camelCase", deny_unknown_fields)]
201#[builder(start_fn = new)]
202pub struct Container {
203 #[builder(start_fn)]
206 pub id: String,
207
208 #[builder(field)]
210 pub links: Vec<ResourceRef>,
211
212 #[builder(field)]
214 pub ports: Vec<ContainerPort>,
215
216 #[serde(skip_serializing_if = "Option::is_none")]
219 pub cluster: Option<String>,
220
221 pub code: ContainerCode,
223
224 pub cpu: ResourceSpec,
226
227 pub memory: ResourceSpec,
229
230 #[serde(skip_serializing_if = "Option::is_none")]
232 pub gpu: Option<ContainerGpuSpec>,
233
234 #[serde(skip_serializing_if = "Option::is_none")]
236 pub ephemeral_storage: Option<String>,
237
238 #[serde(skip_serializing_if = "Option::is_none")]
240 pub persistent_storage: Option<PersistentStorage>,
241
242 #[serde(skip_serializing_if = "Option::is_none")]
244 pub replicas: Option<u32>,
245
246 #[serde(skip_serializing_if = "Option::is_none")]
248 pub autoscaling: Option<ContainerAutoscaling>,
249
250 #[builder(default = false)]
252 #[serde(default)]
253 pub stateful: bool,
254
255 #[builder(default)]
257 #[serde(default)]
258 pub environment: HashMap<String, String>,
259
260 #[serde(skip_serializing_if = "Option::is_none")]
263 pub pool: Option<String>,
264
265 pub permissions: String,
267
268 #[serde(skip_serializing_if = "Option::is_none")]
270 pub health_check: Option<HealthCheck>,
271
272 #[serde(skip_serializing_if = "Option::is_none")]
274 pub command: Option<Vec<String>>,
275}
276
277impl Container {
278 pub const RESOURCE_TYPE: ResourceType = ResourceType::from_static("container");
280
281 pub fn id(&self) -> &str {
283 &self.id
284 }
285
286 pub fn get_permissions(&self) -> &str {
288 &self.permissions
289 }
290
291 pub fn is_stateless(&self) -> bool {
293 !self.stateful
294 }
295
296 fn validate_ports(&self) -> Result<()> {
298 let http_ports: Vec<_> = self
300 .ports
301 .iter()
302 .filter(|p| p.expose == Some(ExposeProtocol::Http))
303 .collect();
304
305 if http_ports.len() > 1 {
306 return Err(AlienError::new(ErrorData::InvalidResourceUpdate {
307 resource_id: self.id.clone(),
308 reason: "at most one port can be exposed with HTTP protocol (multiple TCP ports are allowed)".to_string(),
309 }));
310 }
311
312 Ok(())
313 }
314}
315
316impl<S: container_builder::State> ContainerBuilder<S> {
317 pub fn link<R: ?Sized>(mut self, resource: &R) -> Self
319 where
320 for<'a> &'a R: Into<ResourceRef>,
321 {
322 let resource_ref: ResourceRef = resource.into();
323 self.links.push(resource_ref);
324 self
325 }
326
327 pub fn port(mut self, port: u16) -> Self {
329 self.ports.push(ContainerPort { port, expose: None });
330 self
331 }
332
333 pub fn expose_port(mut self, port: u16, protocol: ExposeProtocol) -> Self {
335 if let Some(existing) = self.ports.iter_mut().find(|p| p.port == port) {
337 existing.expose = Some(protocol);
338 } else {
339 self.ports.push(ContainerPort {
340 port,
341 expose: Some(protocol),
342 });
343 }
344 self
345 }
346}
347
348#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
350#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
351#[serde(rename_all = "camelCase")]
352pub enum ContainerStatus {
353 Pending,
355 Running,
357 Stopped,
359 Failing,
362}
363
364#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
366#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
367#[serde(rename_all = "camelCase")]
368pub struct ReplicaStatus {
369 pub replica_id: String,
371 pub ordinal: Option<u32>,
373 pub machine_id: Option<String>,
375 pub healthy: bool,
377 pub container_ip: Option<String>,
379}
380
381#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
383#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
384#[serde(rename_all = "camelCase")]
385pub struct ContainerOutputs {
386 pub name: String,
388 pub status: ContainerStatus,
390 pub current_replicas: u32,
392 pub desired_replicas: u32,
394 pub internal_dns: String,
396 #[serde(skip_serializing_if = "Option::is_none")]
398 pub url: Option<String>,
399 pub replicas: Vec<ReplicaStatus>,
401 #[serde(skip_serializing_if = "Option::is_none")]
404 pub load_balancer_endpoint: Option<LoadBalancerEndpoint>,
405}
406
407impl ResourceOutputsDefinition for ContainerOutputs {
408 fn get_resource_type(&self) -> ResourceType {
409 Container::RESOURCE_TYPE.clone()
410 }
411
412 fn as_any(&self) -> &dyn Any {
413 self
414 }
415
416 fn box_clone(&self) -> Box<dyn ResourceOutputsDefinition> {
417 Box::new(self.clone())
418 }
419
420 fn outputs_eq(&self, other: &dyn ResourceOutputsDefinition) -> bool {
421 other.as_any().downcast_ref::<ContainerOutputs>() == Some(self)
422 }
423
424 fn to_json_value(&self) -> serde_json::Result<serde_json::Value> {
425 serde_json::to_value(self)
426 }
427}
428
429impl ResourceDefinition for Container {
430 fn get_resource_type(&self) -> ResourceType {
431 Self::RESOURCE_TYPE
432 }
433
434 fn id(&self) -> &str {
435 &self.id
436 }
437
438 fn get_dependencies(&self) -> Vec<ResourceRef> {
439 let mut deps = self.links.clone();
440 if let Some(cluster) = &self.cluster {
443 deps.push(ResourceRef::new(
444 ComputeCluster::RESOURCE_TYPE.clone(),
445 cluster,
446 ));
447 }
448 deps
449 }
450
451 fn get_permissions(&self) -> Option<&str> {
452 Some(&self.permissions)
453 }
454
455 fn validate_update(&self, new_config: &dyn ResourceDefinition) -> Result<()> {
456 let new_container = new_config
457 .as_any()
458 .downcast_ref::<Container>()
459 .ok_or_else(|| {
460 AlienError::new(ErrorData::UnexpectedResourceType {
461 resource_id: self.id.clone(),
462 expected: Self::RESOURCE_TYPE,
463 actual: new_config.get_resource_type(),
464 })
465 })?;
466
467 new_container.validate_ports()?;
469
470 if self.id != new_container.id {
471 return Err(AlienError::new(ErrorData::InvalidResourceUpdate {
472 resource_id: self.id.clone(),
473 reason: "the 'id' field is immutable".to_string(),
474 }));
475 }
476
477 if self.cluster != new_container.cluster {
479 return Err(AlienError::new(ErrorData::InvalidResourceUpdate {
480 resource_id: self.id.clone(),
481 reason: "the 'cluster' field is immutable".to_string(),
482 }));
483 }
484
485 if self.stateful != new_container.stateful {
487 return Err(AlienError::new(ErrorData::InvalidResourceUpdate {
488 resource_id: self.id.clone(),
489 reason: "the 'stateful' field is immutable".to_string(),
490 }));
491 }
492
493 if self.ports != new_container.ports {
495 return Err(AlienError::new(ErrorData::InvalidResourceUpdate {
496 resource_id: self.id.clone(),
497 reason: "the 'ports' field is immutable".to_string(),
498 }));
499 }
500
501 if self.pool != new_container.pool {
503 return Err(AlienError::new(ErrorData::InvalidResourceUpdate {
504 resource_id: self.id.clone(),
505 reason: "the 'pool' field is immutable".to_string(),
506 }));
507 }
508
509 Ok(())
510 }
511
512 fn as_any(&self) -> &dyn Any {
513 self
514 }
515
516 fn as_any_mut(&mut self) -> &mut dyn Any {
517 self
518 }
519
520 fn box_clone(&self) -> Box<dyn ResourceDefinition> {
521 Box::new(self.clone())
522 }
523
524 fn resource_eq(&self, other: &dyn ResourceDefinition) -> bool {
525 other.as_any().downcast_ref::<Container>() == Some(self)
526 }
527
528 fn to_json_value(&self) -> serde_json::Result<serde_json::Value> {
529 serde_json::to_value(self)
530 }
531}
532
533#[cfg(test)]
534mod tests {
535 use super::*;
536
537 #[test]
538 fn test_container_creation_with_autoscaling() {
539 let container = Container::new("api".to_string())
540 .cluster("compute".to_string())
541 .code(ContainerCode::Image {
542 image: "myapp:latest".to_string(),
543 })
544 .cpu(ResourceSpec {
545 min: "0.5".to_string(),
546 desired: "1".to_string(),
547 })
548 .memory(ResourceSpec {
549 min: "512Mi".to_string(),
550 desired: "1Gi".to_string(),
551 })
552 .port(8080)
553 .expose_port(8080, ExposeProtocol::Http)
554 .autoscaling(ContainerAutoscaling {
555 min: 2,
556 desired: 3,
557 max: 10,
558 target_cpu_percent: Some(70.0),
559 target_memory_percent: None,
560 target_http_in_flight_per_replica: Some(100),
561 max_http_p95_latency_ms: None,
562 })
563 .permissions("container-execution".to_string())
564 .build();
565
566 assert_eq!(container.id(), "api");
567 assert_eq!(container.cluster, Some("compute".to_string()));
568 assert!(!container.stateful);
569 assert!(container.autoscaling.is_some());
570 assert_eq!(container.ports.len(), 1);
571 assert_eq!(container.ports[0].port, 8080);
572 }
573
574 #[test]
575 fn test_stateful_container_with_storage() {
576 let container = Container::new("postgres".to_string())
577 .cluster("compute".to_string())
578 .code(ContainerCode::Image {
579 image: "postgres:16".to_string(),
580 })
581 .cpu(ResourceSpec {
582 min: "1".to_string(),
583 desired: "2".to_string(),
584 })
585 .memory(ResourceSpec {
586 min: "2Gi".to_string(),
587 desired: "4Gi".to_string(),
588 })
589 .port(5432)
590 .stateful(true)
591 .replicas(1)
592 .persistent_storage(PersistentStorage {
593 size: "100Gi".to_string(),
594 mount_path: "/var/lib/postgresql/data".to_string(),
595 })
596 .permissions("database".to_string())
597 .build();
598
599 assert_eq!(container.id(), "postgres");
600 assert!(container.stateful);
601 assert!(container.replicas.is_some());
602 assert!(container.persistent_storage.is_some());
603 }
604
605 #[test]
606 fn test_public_container() {
607 let container = Container::new("frontend".to_string())
608 .cluster("compute".to_string())
609 .code(ContainerCode::Image {
610 image: "frontend:latest".to_string(),
611 })
612 .cpu(ResourceSpec {
613 min: "0.25".to_string(),
614 desired: "0.5".to_string(),
615 })
616 .memory(ResourceSpec {
617 min: "256Mi".to_string(),
618 desired: "512Mi".to_string(),
619 })
620 .port(3000)
621 .expose_port(3000, ExposeProtocol::Http)
622 .autoscaling(ContainerAutoscaling {
623 min: 2,
624 desired: 2,
625 max: 20,
626 target_cpu_percent: None,
627 target_memory_percent: None,
628 target_http_in_flight_per_replica: Some(50),
629 max_http_p95_latency_ms: Some(100.0),
630 })
631 .health_check(HealthCheck {
632 path: "/health".to_string(),
633 port: None,
634 method: "GET".to_string(),
635 timeout_seconds: 1,
636 failure_threshold: 3,
637 })
638 .permissions("frontend".to_string())
639 .build();
640
641 assert_eq!(container.ports[0].port, 3000);
642 assert!(container.ports[0].expose.is_some());
643 assert!(container.health_check.is_some());
644 }
645
646 #[test]
647 fn test_container_with_links() {
648 use crate::Storage;
649
650 let storage = Storage::new("data".to_string()).build();
651
652 let container = Container::new("worker".to_string())
653 .cluster("compute".to_string())
654 .code(ContainerCode::Image {
655 image: "worker:latest".to_string(),
656 })
657 .cpu(ResourceSpec {
658 min: "0.5".to_string(),
659 desired: "1".to_string(),
660 })
661 .memory(ResourceSpec {
662 min: "512Mi".to_string(),
663 desired: "1Gi".to_string(),
664 })
665 .port(8080)
666 .replicas(3)
667 .link(&storage)
668 .permissions("worker".to_string())
669 .build();
670
671 let deps = container.get_dependencies();
673 assert_eq!(deps.len(), 2);
674 }
675
676 #[test]
677 fn test_container_validate_update_immutable_cluster() {
678 let container1 = Container::new("api".to_string())
679 .cluster("cluster-1".to_string())
680 .code(ContainerCode::Image {
681 image: "myapp:v1".to_string(),
682 })
683 .cpu(ResourceSpec {
684 min: "0.5".to_string(),
685 desired: "1".to_string(),
686 })
687 .memory(ResourceSpec {
688 min: "512Mi".to_string(),
689 desired: "1Gi".to_string(),
690 })
691 .port(8080)
692 .replicas(2)
693 .permissions("execution".to_string())
694 .build();
695
696 let container2 = Container::new("api".to_string())
697 .cluster("cluster-2".to_string()) .code(ContainerCode::Image {
699 image: "myapp:v2".to_string(),
700 })
701 .cpu(ResourceSpec {
702 min: "0.5".to_string(),
703 desired: "1".to_string(),
704 })
705 .memory(ResourceSpec {
706 min: "512Mi".to_string(),
707 desired: "1Gi".to_string(),
708 })
709 .port(8080)
710 .replicas(2)
711 .permissions("execution".to_string())
712 .build();
713
714 let result = container1.validate_update(&container2);
715 assert!(result.is_err());
716 }
717
718 #[test]
719 fn test_container_validate_update_allowed_changes() {
720 let container1 = Container::new("api".to_string())
721 .cluster("compute".to_string())
722 .code(ContainerCode::Image {
723 image: "myapp:v1".to_string(),
724 })
725 .cpu(ResourceSpec {
726 min: "0.5".to_string(),
727 desired: "1".to_string(),
728 })
729 .memory(ResourceSpec {
730 min: "512Mi".to_string(),
731 desired: "1Gi".to_string(),
732 })
733 .port(8080)
734 .replicas(2)
735 .permissions("execution".to_string())
736 .build();
737
738 let container2 = Container::new("api".to_string())
739 .cluster("compute".to_string())
740 .code(ContainerCode::Image {
741 image: "myapp:v2".to_string(), })
743 .cpu(ResourceSpec {
744 min: "1".to_string(), desired: "2".to_string(),
746 })
747 .memory(ResourceSpec {
748 min: "1Gi".to_string(),
749 desired: "2Gi".to_string(),
750 })
751 .port(8080)
752 .replicas(5) .permissions("execution".to_string())
754 .build();
755
756 let result = container1.validate_update(&container2);
757 assert!(result.is_ok());
758 }
759
760 #[test]
761 fn test_container_serialization() {
762 let container = Container::new("test".to_string())
763 .cluster("compute".to_string())
764 .code(ContainerCode::Image {
765 image: "test:latest".to_string(),
766 })
767 .cpu(ResourceSpec {
768 min: "0.5".to_string(),
769 desired: "1".to_string(),
770 })
771 .memory(ResourceSpec {
772 min: "512Mi".to_string(),
773 desired: "1Gi".to_string(),
774 })
775 .port(8080)
776 .replicas(1)
777 .permissions("test".to_string())
778 .build();
779
780 let json = serde_json::to_string(&container).unwrap();
781 let deserialized: Container = serde_json::from_str(&json).unwrap();
782 assert_eq!(container, deserialized);
783 }
784
785 #[test]
786 fn test_container_multi_port_validation() {
787 let container = Container::new("multi-tcp".to_string())
789 .cluster("compute".to_string())
790 .code(ContainerCode::Image {
791 image: "test:latest".to_string(),
792 })
793 .cpu(ResourceSpec {
794 min: "1".to_string(),
795 desired: "1".to_string(),
796 })
797 .memory(ResourceSpec {
798 min: "1Gi".to_string(),
799 desired: "1Gi".to_string(),
800 })
801 .port(8080)
802 .expose_port(8080, ExposeProtocol::Tcp)
803 .port(9090)
804 .expose_port(9090, ExposeProtocol::Tcp)
805 .replicas(1)
806 .permissions("test".to_string())
807 .build();
808
809 assert!(container.validate_ports().is_ok());
810
811 let invalid_container = Container::new("multi-http".to_string())
813 .cluster("compute".to_string())
814 .code(ContainerCode::Image {
815 image: "test:latest".to_string(),
816 })
817 .cpu(ResourceSpec {
818 min: "1".to_string(),
819 desired: "1".to_string(),
820 })
821 .memory(ResourceSpec {
822 min: "1Gi".to_string(),
823 desired: "1Gi".to_string(),
824 })
825 .port(8080)
826 .expose_port(8080, ExposeProtocol::Http)
827 .port(9090)
828 .expose_port(9090, ExposeProtocol::Http)
829 .replicas(1)
830 .permissions("test".to_string())
831 .build();
832
833 assert!(invalid_container.validate_ports().is_err());
834 }
835
836 #[test]
837 fn test_container_empty_ports_validation() {
838 let container = Container::new("no-ports".to_string())
839 .cluster("compute".to_string())
840 .code(ContainerCode::Image {
841 image: "test:latest".to_string(),
842 })
843 .cpu(ResourceSpec {
844 min: "1".to_string(),
845 desired: "1".to_string(),
846 })
847 .memory(ResourceSpec {
848 min: "1Gi".to_string(),
849 desired: "1Gi".to_string(),
850 })
851 .replicas(1)
852 .permissions("test".to_string())
853 .build();
854
855 assert!(container.validate_ports().is_ok());
856 }
857}