1use crate::error::{ErrorData, Result};
15use crate::resource::{ResourceDefinition, ResourceOutputsDefinition, ResourceRef, ResourceType};
16use crate::resources::{ContainerCluster, 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 #[serde(skip_serializing_if = "Option::is_none")]
80 pub storage_type: Option<String>,
81 #[serde(skip_serializing_if = "Option::is_none")]
83 pub iops: Option<u32>,
84 #[serde(skip_serializing_if = "Option::is_none")]
86 pub throughput: Option<u32>,
87}
88
89#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
91#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
92#[serde(rename_all = "camelCase")]
93pub struct ContainerAutoscaling {
94 pub min: u32,
96 pub desired: u32,
98 pub max: u32,
100 #[serde(skip_serializing_if = "Option::is_none")]
102 pub target_cpu_percent: Option<f64>,
103 #[serde(skip_serializing_if = "Option::is_none")]
105 pub target_memory_percent: Option<f64>,
106 #[serde(skip_serializing_if = "Option::is_none")]
108 pub target_http_in_flight_per_replica: Option<u32>,
109 #[serde(skip_serializing_if = "Option::is_none")]
111 pub max_http_p95_latency_ms: Option<f64>,
112}
113
114#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
116#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
117#[serde(rename_all = "camelCase")]
118pub struct HealthCheck {
119 #[serde(default = "default_health_path")]
121 pub path: String,
122 #[serde(skip_serializing_if = "Option::is_none")]
124 pub port: Option<u16>,
125 #[serde(default = "default_health_method")]
127 pub method: String,
128 #[serde(default = "default_timeout_seconds")]
130 pub timeout_seconds: u32,
131 #[serde(default = "default_failure_threshold")]
133 pub failure_threshold: u32,
134}
135
136fn default_health_path() -> String {
137 "/health".to_string()
138}
139
140fn default_health_method() -> String {
141 "GET".to_string()
142}
143
144fn default_timeout_seconds() -> u32 {
145 1
146}
147
148fn default_failure_threshold() -> u32 {
149 3
150}
151
152#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
154#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
155#[serde(rename_all = "lowercase")]
156pub enum ExposeProtocol {
157 Http,
159 Tcp,
161}
162
163#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
165#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
166#[serde(rename_all = "camelCase")]
167pub struct ContainerPort {
168 pub port: u16,
170 #[serde(skip_serializing_if = "Option::is_none")]
172 pub expose: Option<ExposeProtocol>,
173}
174
175#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Builder)]
208#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
209#[serde(rename_all = "camelCase", deny_unknown_fields)]
210#[builder(start_fn = new)]
211pub struct Container {
212 #[builder(start_fn)]
215 pub id: String,
216
217 #[builder(field)]
219 pub links: Vec<ResourceRef>,
220
221 #[builder(field)]
223 pub ports: Vec<ContainerPort>,
224
225 #[serde(skip_serializing_if = "Option::is_none")]
228 pub cluster: Option<String>,
229
230 pub code: ContainerCode,
232
233 pub cpu: ResourceSpec,
235
236 pub memory: ResourceSpec,
238
239 #[serde(skip_serializing_if = "Option::is_none")]
241 pub gpu: Option<ContainerGpuSpec>,
242
243 #[serde(skip_serializing_if = "Option::is_none")]
245 pub ephemeral_storage: Option<String>,
246
247 #[serde(skip_serializing_if = "Option::is_none")]
249 pub persistent_storage: Option<PersistentStorage>,
250
251 #[serde(skip_serializing_if = "Option::is_none")]
253 pub replicas: Option<u32>,
254
255 #[serde(skip_serializing_if = "Option::is_none")]
257 pub autoscaling: Option<ContainerAutoscaling>,
258
259 #[builder(default = false)]
261 #[serde(default)]
262 pub stateful: bool,
263
264 #[builder(default)]
266 #[serde(default)]
267 pub environment: HashMap<String, String>,
268
269 #[serde(skip_serializing_if = "Option::is_none")]
272 pub pool: Option<String>,
273
274 pub permissions: String,
276
277 #[serde(skip_serializing_if = "Option::is_none")]
279 pub health_check: Option<HealthCheck>,
280
281 #[serde(skip_serializing_if = "Option::is_none")]
283 pub command: Option<Vec<String>>,
284}
285
286impl Container {
287 pub const RESOURCE_TYPE: ResourceType = ResourceType::from_static("container");
289
290 pub fn id(&self) -> &str {
292 &self.id
293 }
294
295 pub fn get_permissions(&self) -> &str {
297 &self.permissions
298 }
299
300 pub fn is_stateless(&self) -> bool {
302 !self.stateful
303 }
304
305 fn validate_ports(&self) -> Result<()> {
307 if self.ports.is_empty() {
309 return Err(AlienError::new(ErrorData::InvalidResourceUpdate {
310 resource_id: self.id.clone(),
311 reason: "at least one port must be specified".to_string(),
312 }));
313 }
314
315 let http_ports: Vec<_> = self
317 .ports
318 .iter()
319 .filter(|p| p.expose == Some(ExposeProtocol::Http))
320 .collect();
321
322 if http_ports.len() > 1 {
323 return Err(AlienError::new(ErrorData::InvalidResourceUpdate {
324 resource_id: self.id.clone(),
325 reason: "at most one port can be exposed with HTTP protocol (multiple TCP ports are allowed)".to_string(),
326 }));
327 }
328
329 Ok(())
330 }
331}
332
333impl<S: container_builder::State> ContainerBuilder<S> {
334 pub fn link<R: ?Sized>(mut self, resource: &R) -> Self
336 where
337 for<'a> &'a R: Into<ResourceRef>,
338 {
339 let resource_ref: ResourceRef = resource.into();
340 self.links.push(resource_ref);
341 self
342 }
343
344 pub fn port(mut self, port: u16) -> Self {
346 self.ports.push(ContainerPort { port, expose: None });
347 self
348 }
349
350 pub fn expose_port(mut self, port: u16, protocol: ExposeProtocol) -> Self {
352 if let Some(existing) = self.ports.iter_mut().find(|p| p.port == port) {
354 existing.expose = Some(protocol);
355 } else {
356 self.ports.push(ContainerPort {
357 port,
358 expose: Some(protocol),
359 });
360 }
361 self
362 }
363}
364
365#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
367#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
368#[serde(rename_all = "camelCase")]
369pub enum ContainerStatus {
370 Pending,
372 Running,
374 Stopped,
376 Failing,
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 ReplicaStatus {
386 pub replica_id: String,
388 pub ordinal: Option<u32>,
390 pub machine_id: Option<String>,
392 pub healthy: bool,
394 pub container_ip: Option<String>,
396}
397
398#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
400#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
401#[serde(rename_all = "camelCase")]
402pub struct ContainerOutputs {
403 pub name: String,
405 pub status: ContainerStatus,
407 pub current_replicas: u32,
409 pub desired_replicas: u32,
411 pub internal_dns: String,
413 #[serde(skip_serializing_if = "Option::is_none")]
415 pub url: Option<String>,
416 pub replicas: Vec<ReplicaStatus>,
418 #[serde(skip_serializing_if = "Option::is_none")]
421 pub load_balancer_endpoint: Option<LoadBalancerEndpoint>,
422}
423
424impl ResourceOutputsDefinition for ContainerOutputs {
425 fn get_resource_type(&self) -> ResourceType {
426 Container::RESOURCE_TYPE.clone()
427 }
428
429 fn as_any(&self) -> &dyn Any {
430 self
431 }
432
433 fn box_clone(&self) -> Box<dyn ResourceOutputsDefinition> {
434 Box::new(self.clone())
435 }
436
437 fn outputs_eq(&self, other: &dyn ResourceOutputsDefinition) -> bool {
438 other.as_any().downcast_ref::<ContainerOutputs>() == Some(self)
439 }
440
441 fn to_json_value(&self) -> serde_json::Result<serde_json::Value> {
442 serde_json::to_value(self)
443 }
444}
445
446impl ResourceDefinition for Container {
447 fn get_resource_type(&self) -> ResourceType {
448 Self::RESOURCE_TYPE
449 }
450
451 fn id(&self) -> &str {
452 &self.id
453 }
454
455 fn get_dependencies(&self) -> Vec<ResourceRef> {
456 let mut deps = self.links.clone();
457 if let Some(cluster) = &self.cluster {
460 deps.push(ResourceRef::new(
461 ContainerCluster::RESOURCE_TYPE.clone(),
462 cluster,
463 ));
464 }
465 deps
466 }
467
468 fn get_permissions(&self) -> Option<&str> {
469 Some(&self.permissions)
470 }
471
472 fn validate_update(&self, new_config: &dyn ResourceDefinition) -> Result<()> {
473 let new_container = new_config
474 .as_any()
475 .downcast_ref::<Container>()
476 .ok_or_else(|| {
477 AlienError::new(ErrorData::UnexpectedResourceType {
478 resource_id: self.id.clone(),
479 expected: Self::RESOURCE_TYPE,
480 actual: new_config.get_resource_type(),
481 })
482 })?;
483
484 new_container.validate_ports()?;
486
487 if self.id != new_container.id {
488 return Err(AlienError::new(ErrorData::InvalidResourceUpdate {
489 resource_id: self.id.clone(),
490 reason: "the 'id' field is immutable".to_string(),
491 }));
492 }
493
494 if self.cluster != new_container.cluster {
496 return Err(AlienError::new(ErrorData::InvalidResourceUpdate {
497 resource_id: self.id.clone(),
498 reason: "the 'cluster' field is immutable".to_string(),
499 }));
500 }
501
502 if self.stateful != new_container.stateful {
504 return Err(AlienError::new(ErrorData::InvalidResourceUpdate {
505 resource_id: self.id.clone(),
506 reason: "the 'stateful' field is immutable".to_string(),
507 }));
508 }
509
510 if self.ports != new_container.ports {
512 return Err(AlienError::new(ErrorData::InvalidResourceUpdate {
513 resource_id: self.id.clone(),
514 reason: "the 'ports' field is immutable".to_string(),
515 }));
516 }
517
518 if self.pool != new_container.pool {
520 return Err(AlienError::new(ErrorData::InvalidResourceUpdate {
521 resource_id: self.id.clone(),
522 reason: "the 'pool' field is immutable".to_string(),
523 }));
524 }
525
526 Ok(())
527 }
528
529 fn as_any(&self) -> &dyn Any {
530 self
531 }
532
533 fn as_any_mut(&mut self) -> &mut dyn Any {
534 self
535 }
536
537 fn box_clone(&self) -> Box<dyn ResourceDefinition> {
538 Box::new(self.clone())
539 }
540
541 fn resource_eq(&self, other: &dyn ResourceDefinition) -> bool {
542 other.as_any().downcast_ref::<Container>() == Some(self)
543 }
544
545 fn to_json_value(&self) -> serde_json::Result<serde_json::Value> {
546 serde_json::to_value(self)
547 }
548}
549
550#[cfg(test)]
551mod tests {
552 use super::*;
553
554 #[test]
555 fn test_container_creation_with_autoscaling() {
556 let container = Container::new("api".to_string())
557 .cluster("compute".to_string())
558 .code(ContainerCode::Image {
559 image: "myapp:latest".to_string(),
560 })
561 .cpu(ResourceSpec {
562 min: "0.5".to_string(),
563 desired: "1".to_string(),
564 })
565 .memory(ResourceSpec {
566 min: "512Mi".to_string(),
567 desired: "1Gi".to_string(),
568 })
569 .port(8080)
570 .expose_port(8080, ExposeProtocol::Http)
571 .autoscaling(ContainerAutoscaling {
572 min: 2,
573 desired: 3,
574 max: 10,
575 target_cpu_percent: Some(70.0),
576 target_memory_percent: None,
577 target_http_in_flight_per_replica: Some(100),
578 max_http_p95_latency_ms: None,
579 })
580 .permissions("container-execution".to_string())
581 .build();
582
583 assert_eq!(container.id(), "api");
584 assert_eq!(container.cluster, Some("compute".to_string()));
585 assert!(!container.stateful);
586 assert!(container.autoscaling.is_some());
587 assert_eq!(container.ports.len(), 1);
588 assert_eq!(container.ports[0].port, 8080);
589 }
590
591 #[test]
592 fn test_stateful_container_with_storage() {
593 let container = Container::new("postgres".to_string())
594 .cluster("compute".to_string())
595 .code(ContainerCode::Image {
596 image: "postgres:16".to_string(),
597 })
598 .cpu(ResourceSpec {
599 min: "1".to_string(),
600 desired: "2".to_string(),
601 })
602 .memory(ResourceSpec {
603 min: "2Gi".to_string(),
604 desired: "4Gi".to_string(),
605 })
606 .port(5432)
607 .stateful(true)
608 .replicas(1)
609 .persistent_storage(PersistentStorage {
610 size: "100Gi".to_string(),
611 mount_path: "/var/lib/postgresql/data".to_string(),
612 storage_type: Some("gp3".to_string()),
613 iops: Some(3000),
614 throughput: Some(125),
615 })
616 .permissions("database".to_string())
617 .build();
618
619 assert_eq!(container.id(), "postgres");
620 assert!(container.stateful);
621 assert!(container.replicas.is_some());
622 assert!(container.persistent_storage.is_some());
623 }
624
625 #[test]
626 fn test_public_container() {
627 let container = Container::new("frontend".to_string())
628 .cluster("compute".to_string())
629 .code(ContainerCode::Image {
630 image: "frontend:latest".to_string(),
631 })
632 .cpu(ResourceSpec {
633 min: "0.25".to_string(),
634 desired: "0.5".to_string(),
635 })
636 .memory(ResourceSpec {
637 min: "256Mi".to_string(),
638 desired: "512Mi".to_string(),
639 })
640 .port(3000)
641 .expose_port(3000, ExposeProtocol::Http)
642 .autoscaling(ContainerAutoscaling {
643 min: 2,
644 desired: 2,
645 max: 20,
646 target_cpu_percent: None,
647 target_memory_percent: None,
648 target_http_in_flight_per_replica: Some(50),
649 max_http_p95_latency_ms: Some(100.0),
650 })
651 .health_check(HealthCheck {
652 path: "/health".to_string(),
653 port: None,
654 method: "GET".to_string(),
655 timeout_seconds: 1,
656 failure_threshold: 3,
657 })
658 .permissions("frontend".to_string())
659 .build();
660
661 assert_eq!(container.ports[0].port, 3000);
662 assert!(container.ports[0].expose.is_some());
663 assert!(container.health_check.is_some());
664 }
665
666 #[test]
667 fn test_container_with_links() {
668 use crate::Storage;
669
670 let storage = Storage::new("data".to_string()).build();
671
672 let container = Container::new("worker".to_string())
673 .cluster("compute".to_string())
674 .code(ContainerCode::Image {
675 image: "worker:latest".to_string(),
676 })
677 .cpu(ResourceSpec {
678 min: "0.5".to_string(),
679 desired: "1".to_string(),
680 })
681 .memory(ResourceSpec {
682 min: "512Mi".to_string(),
683 desired: "1Gi".to_string(),
684 })
685 .port(8080)
686 .replicas(3)
687 .link(&storage)
688 .permissions("worker".to_string())
689 .build();
690
691 let deps = container.get_dependencies();
693 assert_eq!(deps.len(), 2);
694 }
695
696 #[test]
697 fn test_container_validate_update_immutable_cluster() {
698 let container1 = Container::new("api".to_string())
699 .cluster("cluster-1".to_string())
700 .code(ContainerCode::Image {
701 image: "myapp:v1".to_string(),
702 })
703 .cpu(ResourceSpec {
704 min: "0.5".to_string(),
705 desired: "1".to_string(),
706 })
707 .memory(ResourceSpec {
708 min: "512Mi".to_string(),
709 desired: "1Gi".to_string(),
710 })
711 .port(8080)
712 .replicas(2)
713 .permissions("execution".to_string())
714 .build();
715
716 let container2 = Container::new("api".to_string())
717 .cluster("cluster-2".to_string()) .code(ContainerCode::Image {
719 image: "myapp:v2".to_string(),
720 })
721 .cpu(ResourceSpec {
722 min: "0.5".to_string(),
723 desired: "1".to_string(),
724 })
725 .memory(ResourceSpec {
726 min: "512Mi".to_string(),
727 desired: "1Gi".to_string(),
728 })
729 .port(8080)
730 .replicas(2)
731 .permissions("execution".to_string())
732 .build();
733
734 let result = container1.validate_update(&container2);
735 assert!(result.is_err());
736 }
737
738 #[test]
739 fn test_container_validate_update_allowed_changes() {
740 let container1 = Container::new("api".to_string())
741 .cluster("compute".to_string())
742 .code(ContainerCode::Image {
743 image: "myapp:v1".to_string(),
744 })
745 .cpu(ResourceSpec {
746 min: "0.5".to_string(),
747 desired: "1".to_string(),
748 })
749 .memory(ResourceSpec {
750 min: "512Mi".to_string(),
751 desired: "1Gi".to_string(),
752 })
753 .port(8080)
754 .replicas(2)
755 .permissions("execution".to_string())
756 .build();
757
758 let container2 = Container::new("api".to_string())
759 .cluster("compute".to_string())
760 .code(ContainerCode::Image {
761 image: "myapp:v2".to_string(), })
763 .cpu(ResourceSpec {
764 min: "1".to_string(), desired: "2".to_string(),
766 })
767 .memory(ResourceSpec {
768 min: "1Gi".to_string(),
769 desired: "2Gi".to_string(),
770 })
771 .port(8080)
772 .replicas(5) .permissions("execution".to_string())
774 .build();
775
776 let result = container1.validate_update(&container2);
777 assert!(result.is_ok());
778 }
779
780 #[test]
781 fn test_container_serialization() {
782 let container = Container::new("test".to_string())
783 .cluster("compute".to_string())
784 .code(ContainerCode::Image {
785 image: "test:latest".to_string(),
786 })
787 .cpu(ResourceSpec {
788 min: "0.5".to_string(),
789 desired: "1".to_string(),
790 })
791 .memory(ResourceSpec {
792 min: "512Mi".to_string(),
793 desired: "1Gi".to_string(),
794 })
795 .port(8080)
796 .replicas(1)
797 .permissions("test".to_string())
798 .build();
799
800 let json = serde_json::to_string(&container).unwrap();
801 let deserialized: Container = serde_json::from_str(&json).unwrap();
802 assert_eq!(container, deserialized);
803 }
804
805 #[test]
806 fn test_container_multi_port_validation() {
807 let container = Container::new("multi-tcp".to_string())
809 .cluster("compute".to_string())
810 .code(ContainerCode::Image {
811 image: "test:latest".to_string(),
812 })
813 .cpu(ResourceSpec {
814 min: "1".to_string(),
815 desired: "1".to_string(),
816 })
817 .memory(ResourceSpec {
818 min: "1Gi".to_string(),
819 desired: "1Gi".to_string(),
820 })
821 .port(8080)
822 .expose_port(8080, ExposeProtocol::Tcp)
823 .port(9090)
824 .expose_port(9090, ExposeProtocol::Tcp)
825 .replicas(1)
826 .permissions("test".to_string())
827 .build();
828
829 assert!(container.validate_ports().is_ok());
830
831 let invalid_container = Container::new("multi-http".to_string())
833 .cluster("compute".to_string())
834 .code(ContainerCode::Image {
835 image: "test:latest".to_string(),
836 })
837 .cpu(ResourceSpec {
838 min: "1".to_string(),
839 desired: "1".to_string(),
840 })
841 .memory(ResourceSpec {
842 min: "1Gi".to_string(),
843 desired: "1Gi".to_string(),
844 })
845 .port(8080)
846 .expose_port(8080, ExposeProtocol::Http)
847 .port(9090)
848 .expose_port(9090, ExposeProtocol::Http)
849 .replicas(1)
850 .permissions("test".to_string())
851 .build();
852
853 assert!(invalid_container.validate_ports().is_err());
854 }
855
856 #[test]
857 fn test_container_empty_ports_validation() {
858 let mut container = Container::new("no-ports".to_string())
860 .cluster("compute".to_string())
861 .code(ContainerCode::Image {
862 image: "test:latest".to_string(),
863 })
864 .cpu(ResourceSpec {
865 min: "1".to_string(),
866 desired: "1".to_string(),
867 })
868 .memory(ResourceSpec {
869 min: "1Gi".to_string(),
870 desired: "1Gi".to_string(),
871 })
872 .port(8080) .replicas(1)
874 .permissions("test".to_string())
875 .build();
876
877 container.ports.clear();
879 assert!(container.validate_ports().is_err());
880 }
881}