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
424#[typetag::serde(name = "container")]
425impl ResourceOutputsDefinition for ContainerOutputs {
426 fn resource_type() -> ResourceType {
427 Container::RESOURCE_TYPE.clone()
428 }
429
430 fn as_any(&self) -> &dyn Any {
431 self
432 }
433
434 fn box_clone(&self) -> Box<dyn ResourceOutputsDefinition> {
435 Box::new(self.clone())
436 }
437
438 fn outputs_eq(&self, other: &dyn ResourceOutputsDefinition) -> bool {
439 other.as_any().downcast_ref::<ContainerOutputs>() == Some(self)
440 }
441}
442
443#[typetag::serde(name = "container")]
444impl ResourceDefinition for Container {
445 fn resource_type() -> ResourceType {
446 Self::RESOURCE_TYPE.clone()
447 }
448
449 fn get_resource_type(&self) -> ResourceType {
450 Self::resource_type()
451 }
452
453 fn id(&self) -> &str {
454 &self.id
455 }
456
457 fn get_dependencies(&self) -> Vec<ResourceRef> {
458 let mut deps = self.links.clone();
459 if let Some(cluster) = &self.cluster {
462 deps.push(ResourceRef::new(
463 ContainerCluster::RESOURCE_TYPE.clone(),
464 cluster,
465 ));
466 }
467 deps
468 }
469
470 fn get_permissions(&self) -> Option<&str> {
471 Some(&self.permissions)
472 }
473
474 fn validate_update(&self, new_config: &dyn ResourceDefinition) -> Result<()> {
475 let new_container = new_config
476 .as_any()
477 .downcast_ref::<Container>()
478 .ok_or_else(|| {
479 AlienError::new(ErrorData::UnexpectedResourceType {
480 resource_id: self.id.clone(),
481 expected: Self::RESOURCE_TYPE,
482 actual: new_config.get_resource_type(),
483 })
484 })?;
485
486 new_container.validate_ports()?;
488
489 if self.id != new_container.id {
490 return Err(AlienError::new(ErrorData::InvalidResourceUpdate {
491 resource_id: self.id.clone(),
492 reason: "the 'id' field is immutable".to_string(),
493 }));
494 }
495
496 if self.cluster != new_container.cluster {
498 return Err(AlienError::new(ErrorData::InvalidResourceUpdate {
499 resource_id: self.id.clone(),
500 reason: "the 'cluster' field is immutable".to_string(),
501 }));
502 }
503
504 if self.stateful != new_container.stateful {
506 return Err(AlienError::new(ErrorData::InvalidResourceUpdate {
507 resource_id: self.id.clone(),
508 reason: "the 'stateful' field is immutable".to_string(),
509 }));
510 }
511
512 if self.ports != new_container.ports {
514 return Err(AlienError::new(ErrorData::InvalidResourceUpdate {
515 resource_id: self.id.clone(),
516 reason: "the 'ports' field is immutable".to_string(),
517 }));
518 }
519
520 if self.pool != new_container.pool {
522 return Err(AlienError::new(ErrorData::InvalidResourceUpdate {
523 resource_id: self.id.clone(),
524 reason: "the 'pool' field is immutable".to_string(),
525 }));
526 }
527
528 Ok(())
529 }
530
531 fn as_any(&self) -> &dyn Any {
532 self
533 }
534
535 fn as_any_mut(&mut self) -> &mut dyn Any {
536 self
537 }
538
539 fn box_clone(&self) -> Box<dyn ResourceDefinition> {
540 Box::new(self.clone())
541 }
542
543 fn resource_eq(&self, other: &dyn ResourceDefinition) -> bool {
544 other.as_any().downcast_ref::<Container>() == Some(self)
545 }
546}
547
548#[cfg(test)]
549mod tests {
550 use super::*;
551
552 #[test]
553 fn test_container_creation_with_autoscaling() {
554 let container = Container::new("api".to_string())
555 .cluster("compute".to_string())
556 .code(ContainerCode::Image {
557 image: "myapp:latest".to_string(),
558 })
559 .cpu(ResourceSpec {
560 min: "0.5".to_string(),
561 desired: "1".to_string(),
562 })
563 .memory(ResourceSpec {
564 min: "512Mi".to_string(),
565 desired: "1Gi".to_string(),
566 })
567 .port(8080)
568 .expose_port(8080, ExposeProtocol::Http)
569 .autoscaling(ContainerAutoscaling {
570 min: 2,
571 desired: 3,
572 max: 10,
573 target_cpu_percent: Some(70.0),
574 target_memory_percent: None,
575 target_http_in_flight_per_replica: Some(100),
576 max_http_p95_latency_ms: None,
577 })
578 .permissions("container-execution".to_string())
579 .build();
580
581 assert_eq!(container.id(), "api");
582 assert_eq!(container.cluster, Some("compute".to_string()));
583 assert!(!container.stateful);
584 assert!(container.autoscaling.is_some());
585 assert_eq!(container.ports.len(), 1);
586 assert_eq!(container.ports[0].port, 8080);
587 }
588
589 #[test]
590 fn test_stateful_container_with_storage() {
591 let container = Container::new("postgres".to_string())
592 .cluster("compute".to_string())
593 .code(ContainerCode::Image {
594 image: "postgres:16".to_string(),
595 })
596 .cpu(ResourceSpec {
597 min: "1".to_string(),
598 desired: "2".to_string(),
599 })
600 .memory(ResourceSpec {
601 min: "2Gi".to_string(),
602 desired: "4Gi".to_string(),
603 })
604 .port(5432)
605 .stateful(true)
606 .replicas(1)
607 .persistent_storage(PersistentStorage {
608 size: "100Gi".to_string(),
609 mount_path: "/var/lib/postgresql/data".to_string(),
610 storage_type: Some("gp3".to_string()),
611 iops: Some(3000),
612 throughput: Some(125),
613 })
614 .permissions("database".to_string())
615 .build();
616
617 assert_eq!(container.id(), "postgres");
618 assert!(container.stateful);
619 assert!(container.replicas.is_some());
620 assert!(container.persistent_storage.is_some());
621 }
622
623 #[test]
624 fn test_public_container() {
625 let container = Container::new("frontend".to_string())
626 .cluster("compute".to_string())
627 .code(ContainerCode::Image {
628 image: "frontend:latest".to_string(),
629 })
630 .cpu(ResourceSpec {
631 min: "0.25".to_string(),
632 desired: "0.5".to_string(),
633 })
634 .memory(ResourceSpec {
635 min: "256Mi".to_string(),
636 desired: "512Mi".to_string(),
637 })
638 .port(3000)
639 .expose_port(3000, ExposeProtocol::Http)
640 .autoscaling(ContainerAutoscaling {
641 min: 2,
642 desired: 2,
643 max: 20,
644 target_cpu_percent: None,
645 target_memory_percent: None,
646 target_http_in_flight_per_replica: Some(50),
647 max_http_p95_latency_ms: Some(100.0),
648 })
649 .health_check(HealthCheck {
650 path: "/health".to_string(),
651 port: None,
652 method: "GET".to_string(),
653 timeout_seconds: 1,
654 failure_threshold: 3,
655 })
656 .permissions("frontend".to_string())
657 .build();
658
659 assert_eq!(container.ports[0].port, 3000);
660 assert!(container.ports[0].expose.is_some());
661 assert!(container.health_check.is_some());
662 }
663
664 #[test]
665 fn test_container_with_links() {
666 use crate::Storage;
667
668 let storage = Storage::new("data".to_string()).build();
669
670 let container = Container::new("worker".to_string())
671 .cluster("compute".to_string())
672 .code(ContainerCode::Image {
673 image: "worker:latest".to_string(),
674 })
675 .cpu(ResourceSpec {
676 min: "0.5".to_string(),
677 desired: "1".to_string(),
678 })
679 .memory(ResourceSpec {
680 min: "512Mi".to_string(),
681 desired: "1Gi".to_string(),
682 })
683 .port(8080)
684 .replicas(3)
685 .link(&storage)
686 .permissions("worker".to_string())
687 .build();
688
689 let deps = container.get_dependencies();
691 assert_eq!(deps.len(), 2);
692 }
693
694 #[test]
695 fn test_container_validate_update_immutable_cluster() {
696 let container1 = Container::new("api".to_string())
697 .cluster("cluster-1".to_string())
698 .code(ContainerCode::Image {
699 image: "myapp:v1".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 container2 = Container::new("api".to_string())
715 .cluster("cluster-2".to_string()) .code(ContainerCode::Image {
717 image: "myapp:v2".to_string(),
718 })
719 .cpu(ResourceSpec {
720 min: "0.5".to_string(),
721 desired: "1".to_string(),
722 })
723 .memory(ResourceSpec {
724 min: "512Mi".to_string(),
725 desired: "1Gi".to_string(),
726 })
727 .port(8080)
728 .replicas(2)
729 .permissions("execution".to_string())
730 .build();
731
732 let result = container1.validate_update(&container2);
733 assert!(result.is_err());
734 }
735
736 #[test]
737 fn test_container_validate_update_allowed_changes() {
738 let container1 = Container::new("api".to_string())
739 .cluster("compute".to_string())
740 .code(ContainerCode::Image {
741 image: "myapp:v1".to_string(),
742 })
743 .cpu(ResourceSpec {
744 min: "0.5".to_string(),
745 desired: "1".to_string(),
746 })
747 .memory(ResourceSpec {
748 min: "512Mi".to_string(),
749 desired: "1Gi".to_string(),
750 })
751 .port(8080)
752 .replicas(2)
753 .permissions("execution".to_string())
754 .build();
755
756 let container2 = Container::new("api".to_string())
757 .cluster("compute".to_string())
758 .code(ContainerCode::Image {
759 image: "myapp:v2".to_string(), })
761 .cpu(ResourceSpec {
762 min: "1".to_string(), desired: "2".to_string(),
764 })
765 .memory(ResourceSpec {
766 min: "1Gi".to_string(),
767 desired: "2Gi".to_string(),
768 })
769 .port(8080)
770 .replicas(5) .permissions("execution".to_string())
772 .build();
773
774 let result = container1.validate_update(&container2);
775 assert!(result.is_ok());
776 }
777
778 #[test]
779 fn test_container_serialization() {
780 let container = Container::new("test".to_string())
781 .cluster("compute".to_string())
782 .code(ContainerCode::Image {
783 image: "test:latest".to_string(),
784 })
785 .cpu(ResourceSpec {
786 min: "0.5".to_string(),
787 desired: "1".to_string(),
788 })
789 .memory(ResourceSpec {
790 min: "512Mi".to_string(),
791 desired: "1Gi".to_string(),
792 })
793 .port(8080)
794 .replicas(1)
795 .permissions("test".to_string())
796 .build();
797
798 let json = serde_json::to_string(&container).unwrap();
799 let deserialized: Container = serde_json::from_str(&json).unwrap();
800 assert_eq!(container, deserialized);
801 }
802
803 #[test]
804 fn test_container_multi_port_validation() {
805 let container = Container::new("multi-tcp".to_string())
807 .cluster("compute".to_string())
808 .code(ContainerCode::Image {
809 image: "test:latest".to_string(),
810 })
811 .cpu(ResourceSpec {
812 min: "1".to_string(),
813 desired: "1".to_string(),
814 })
815 .memory(ResourceSpec {
816 min: "1Gi".to_string(),
817 desired: "1Gi".to_string(),
818 })
819 .port(8080)
820 .expose_port(8080, ExposeProtocol::Tcp)
821 .port(9090)
822 .expose_port(9090, ExposeProtocol::Tcp)
823 .replicas(1)
824 .permissions("test".to_string())
825 .build();
826
827 assert!(container.validate_ports().is_ok());
828
829 let invalid_container = Container::new("multi-http".to_string())
831 .cluster("compute".to_string())
832 .code(ContainerCode::Image {
833 image: "test:latest".to_string(),
834 })
835 .cpu(ResourceSpec {
836 min: "1".to_string(),
837 desired: "1".to_string(),
838 })
839 .memory(ResourceSpec {
840 min: "1Gi".to_string(),
841 desired: "1Gi".to_string(),
842 })
843 .port(8080)
844 .expose_port(8080, ExposeProtocol::Http)
845 .port(9090)
846 .expose_port(9090, ExposeProtocol::Http)
847 .replicas(1)
848 .permissions("test".to_string())
849 .build();
850
851 assert!(invalid_container.validate_ports().is_err());
852 }
853
854 #[test]
855 fn test_container_empty_ports_validation() {
856 let mut container = Container::new("no-ports".to_string())
858 .cluster("compute".to_string())
859 .code(ContainerCode::Image {
860 image: "test:latest".to_string(),
861 })
862 .cpu(ResourceSpec {
863 min: "1".to_string(),
864 desired: "1".to_string(),
865 })
866 .memory(ResourceSpec {
867 min: "1Gi".to_string(),
868 desired: "1Gi".to_string(),
869 })
870 .port(8080) .replicas(1)
872 .permissions("test".to_string())
873 .build();
874
875 container.ports.clear();
877 assert!(container.validate_ports().is_err());
878 }
879}