1use crate::error::{ErrorData, Result};
15use crate::resource::{ResourceDefinition, ResourceOutputsDefinition, ResourceRef, ResourceType};
16use crate::resources::{
17 ComputeCluster, PublicEndpoint, PublicEndpointOutput, ToolchainConfig, APEX_HOST_LABEL,
18};
19use alien_error::AlienError;
20use bon::Builder;
21use serde::{Deserialize, Serialize};
22use std::any::Any;
23use std::collections::HashMap;
24use std::fmt::Debug;
25
26#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
28#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
29#[serde(rename_all = "camelCase", tag = "type")]
30pub enum ContainerCode {
31 #[serde(rename_all = "camelCase")]
33 Image {
34 image: String,
36 },
37 #[serde(rename_all = "camelCase")]
39 Source {
40 src: String,
42 toolchain: ToolchainConfig,
44 },
45}
46
47#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
49#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
50#[serde(rename_all = "camelCase")]
51pub struct ResourceSpec {
52 pub min: String,
54 pub desired: String,
56}
57
58#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
60#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
61#[serde(rename_all = "camelCase")]
62pub struct ContainerGpuSpec {
63 #[serde(rename = "type")]
65 pub gpu_type: String,
66 pub count: u32,
68}
69
70#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
72#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
73#[serde(rename_all = "camelCase")]
74pub struct PersistentStorage {
75 pub size: String,
77 pub mount_path: String,
79 #[serde(default)]
81 pub backups: VolumeBackups,
82}
83
84pub const VOLUME_BACKUP_INTERVAL_HOURS: [u32; 7] = [1, 2, 4, 6, 8, 12, 24];
86
87pub const VOLUME_BACKUP_MAX_SNAPSHOTS: u32 = 450;
91
92pub const VOLUME_BACKUP_MAX_RETENTION_DAYS: u32 = 365;
95
96#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
108#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
109#[serde(rename_all = "camelCase")]
110pub struct VolumeBackups {
111 #[serde(default = "default_volume_backups_enabled")]
113 pub enabled: bool,
114 #[serde(default = "default_volume_backup_interval_hours")]
116 pub interval_hours: u32,
117 #[serde(default = "default_volume_backup_retention_days")]
119 pub retention_days: u32,
120}
121
122fn default_volume_backups_enabled() -> bool {
123 true
124}
125
126fn default_volume_backup_interval_hours() -> u32 {
127 24
128}
129
130fn default_volume_backup_retention_days() -> u32 {
131 7
132}
133
134impl Default for VolumeBackups {
135 fn default() -> Self {
136 Self {
137 enabled: default_volume_backups_enabled(),
138 interval_hours: default_volume_backup_interval_hours(),
139 retention_days: default_volume_backup_retention_days(),
140 }
141 }
142}
143
144impl VolumeBackups {
145 pub fn disabled() -> Self {
147 Self {
148 enabled: false,
149 ..Self::default()
150 }
151 }
152
153 pub fn validation_error(&self) -> Option<String> {
155 if !self.enabled {
156 return None;
157 }
158 if !VOLUME_BACKUP_INTERVAL_HOURS.contains(&self.interval_hours) {
159 return Some(format!(
160 "backup intervalHours must be one of {VOLUME_BACKUP_INTERVAL_HOURS:?}, got {}",
161 self.interval_hours
162 ));
163 }
164 if self.retention_days == 0 {
165 return Some("backup retentionDays must be at least 1".to_string());
166 }
167 if self.retention_days > VOLUME_BACKUP_MAX_RETENTION_DAYS {
168 return Some(format!(
169 "backup retentionDays must be at most {VOLUME_BACKUP_MAX_RETENTION_DAYS}, got {}",
170 self.retention_days
171 ));
172 }
173 let snapshots = self.retention_days * 24 / self.interval_hours;
174 if snapshots > VOLUME_BACKUP_MAX_SNAPSHOTS {
175 return Some(format!(
176 "backups every {} hours for {} days keep {snapshots} snapshots per volume; \
177 the most is {VOLUME_BACKUP_MAX_SNAPSHOTS}",
178 self.interval_hours, self.retention_days
179 ));
180 }
181 None
182 }
183}
184
185#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
188#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
189#[serde(rename_all = "camelCase")]
190pub struct KubernetesSecretMount {
191 pub secret_name: String,
193 pub mount_path: String,
195}
196
197#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
199#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
200#[serde(rename_all = "camelCase")]
201pub struct KubernetesHttpProbe {
202 pub path: String,
204 pub port: u16,
206}
207
208#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
210#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
211#[serde(rename_all = "camelCase")]
212pub enum ContainerSecurityProfile {
213 Restricted,
214}
215
216#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
218#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
219#[serde(rename_all = "camelCase")]
220pub struct ContainerSecurity {
221 pub profile: ContainerSecurityProfile,
223 pub run_as_user: i64,
225 pub run_as_group: i64,
227 pub read_only_root_filesystem: bool,
229}
230
231#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
233#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
234#[serde(rename_all = "camelCase")]
235pub struct ContainerAutoscaling {
236 pub min: u32,
238 pub desired: u32,
240 pub max: u32,
242 #[serde(skip_serializing_if = "Option::is_none")]
244 pub target_cpu_percent: Option<f64>,
245 #[serde(skip_serializing_if = "Option::is_none")]
247 pub target_memory_percent: Option<f64>,
248 #[serde(skip_serializing_if = "Option::is_none")]
250 pub target_http_in_flight_per_replica: Option<u32>,
251 #[serde(skip_serializing_if = "Option::is_none")]
253 pub max_http_p95_latency_ms: Option<f64>,
254}
255
256#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
258#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
259#[serde(rename_all = "camelCase")]
260pub struct HealthCheck {
261 #[serde(default = "default_health_path")]
263 pub path: String,
264 #[serde(skip_serializing_if = "Option::is_none")]
266 pub port: Option<u16>,
267 #[serde(default = "default_health_method")]
269 pub method: String,
270 #[serde(default = "default_timeout_seconds")]
272 pub timeout_seconds: u32,
273 #[serde(default = "default_failure_threshold")]
275 pub failure_threshold: u32,
276}
277
278fn default_health_path() -> String {
279 "/health".to_string()
280}
281
282fn default_health_method() -> String {
283 "GET".to_string()
284}
285
286fn default_timeout_seconds() -> u32 {
287 1
288}
289
290fn default_failure_threshold() -> u32 {
291 3
292}
293
294#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
296#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
297#[serde(rename_all = "camelCase")]
298pub struct ContainerPort {
299 pub port: u16,
301}
302
303#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Builder)]
342#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
343#[serde(rename_all = "camelCase", deny_unknown_fields)]
344#[builder(start_fn = new)]
345pub struct Container {
346 #[builder(start_fn)]
349 pub id: String,
350
351 #[builder(field)]
353 pub links: Vec<ResourceRef>,
354
355 #[builder(field)]
357 pub ports: Vec<ContainerPort>,
358
359 #[builder(field)]
361 #[serde(default, skip_serializing_if = "Vec::is_empty")]
362 pub kubernetes_secret_mounts: Vec<KubernetesSecretMount>,
363
364 #[builder(field)]
366 #[serde(default, skip_serializing_if = "Vec::is_empty")]
367 pub public_endpoints: Vec<PublicEndpoint>,
368
369 #[serde(default, skip_serializing_if = "Option::is_none")]
373 pub tunnel: Option<ContainerTunnel>,
374
375 #[serde(skip_serializing_if = "Option::is_none")]
377 pub kubernetes_liveness_probe: Option<KubernetesHttpProbe>,
378
379 #[serde(skip_serializing_if = "Option::is_none")]
381 pub kubernetes_readiness_probe: Option<KubernetesHttpProbe>,
382
383 #[serde(skip_serializing_if = "Option::is_none")]
385 pub security: Option<ContainerSecurity>,
386
387 #[serde(skip_serializing_if = "Option::is_none")]
390 pub cluster: Option<String>,
391
392 pub code: ContainerCode,
394
395 pub cpu: ResourceSpec,
397
398 pub memory: ResourceSpec,
400
401 #[serde(skip_serializing_if = "Option::is_none")]
403 pub gpu: Option<ContainerGpuSpec>,
404
405 #[serde(skip_serializing_if = "Option::is_none")]
407 pub ephemeral_storage: Option<String>,
408
409 #[serde(skip_serializing_if = "Option::is_none")]
411 pub persistent_storage: Option<PersistentStorage>,
412
413 #[serde(skip_serializing_if = "Option::is_none")]
415 pub replicas: Option<u32>,
416
417 #[serde(skip_serializing_if = "Option::is_none")]
419 pub autoscaling: Option<ContainerAutoscaling>,
420
421 #[builder(default = false)]
423 #[serde(default)]
424 pub stateful: bool,
425
426 #[builder(default)]
428 #[serde(default)]
429 pub environment: HashMap<String, String>,
430
431 #[serde(skip_serializing_if = "Option::is_none")]
434 pub pool: Option<String>,
435
436 pub permissions: String,
438
439 #[serde(skip_serializing_if = "Option::is_none")]
441 pub health_check: Option<HealthCheck>,
442
443 #[serde(skip_serializing_if = "Option::is_none")]
445 pub command: Option<Vec<String>>,
446
447 #[builder(default = default_commands_enabled())]
451 #[serde(default = "default_commands_enabled")]
452 #[cfg_attr(feature = "openapi", schema(default = default_commands_enabled))]
453 pub commands_enabled: bool,
454
455 #[serde(skip_serializing_if = "Option::is_none")]
460 #[cfg_attr(feature = "openapi", schema(minimum = 1, maximum = 86400))]
461 pub stop_grace_period_seconds: Option<u32>,
462}
463
464impl Container {
465 pub const RESOURCE_TYPE: ResourceType = ResourceType::from_static("container");
467
468 pub fn id(&self) -> &str {
470 &self.id
471 }
472
473 pub fn get_permissions(&self) -> &str {
475 &self.permissions
476 }
477
478 pub fn is_stateless(&self) -> bool {
480 !self.stateful
481 }
482
483 fn validate_public_endpoints(&self) -> Result<()> {
485 let mut endpoint_names = std::collections::HashSet::new();
486 let mut backend_ports = std::collections::HashSet::new();
487 let mut apex_endpoint_name: Option<&str> = None;
488
489 for endpoint in &self.public_endpoints {
490 endpoint.validate_for_resource(&self.id)?;
491
492 if !endpoint_names.insert(endpoint.name.as_str()) {
493 return Err(AlienError::new(ErrorData::InvalidResourceUpdate {
494 resource_id: self.id.clone(),
495 reason: format!("duplicate public endpoint name '{}'", endpoint.name),
496 }));
497 }
498
499 if endpoint.host_label.as_deref() == Some(APEX_HOST_LABEL) {
500 if let Some(existing_name) = apex_endpoint_name {
501 return Err(AlienError::new(ErrorData::InvalidResourceUpdate {
502 resource_id: self.id.clone(),
503 reason: format!(
504 "only one apex public endpoint is allowed per resource; '{}' already uses hostLabel '@'",
505 existing_name
506 ),
507 }));
508 }
509 apex_endpoint_name = Some(endpoint.name.as_str());
510 }
511
512 backend_ports.insert(endpoint.port);
513
514 if !self.ports.iter().any(|port| port.port == endpoint.port) {
515 return Err(AlienError::new(ErrorData::InvalidResourceUpdate {
516 resource_id: self.id.clone(),
517 reason: format!(
518 "public endpoint '{}' references undeclared port {}",
519 endpoint.name, endpoint.port
520 ),
521 }));
522 }
523 }
524
525 if backend_ports.len() > 1 {
526 return Err(AlienError::new(ErrorData::InvalidResourceUpdate {
527 resource_id: self.id.clone(),
528 reason:
529 "public endpoints on one container must currently route to the same backend port"
530 .to_string(),
531 }));
532 }
533
534 Ok(())
535 }
536}
537
538fn default_commands_enabled() -> bool {
539 false
540}
541
542impl<S: container_builder::State> ContainerBuilder<S> {
543 pub fn link<R: ?Sized>(mut self, resource: &R) -> Self
545 where
546 for<'a> &'a R: Into<ResourceRef>,
547 {
548 let resource_ref: ResourceRef = resource.into();
549 self.links.push(resource_ref);
550 self
551 }
552
553 pub fn port(mut self, port: u16) -> Self {
555 self.ports.push(ContainerPort { port });
556 self
557 }
558
559 pub fn kubernetes_secret_mount(mut self, mount: KubernetesSecretMount) -> Self {
561 self.kubernetes_secret_mounts.push(mount);
562 self
563 }
564
565 pub fn public_endpoint(mut self, endpoint: PublicEndpoint) -> Self {
567 if !self.ports.iter().any(|p| p.port == endpoint.port) {
568 self.ports.push(ContainerPort {
569 port: endpoint.port,
570 });
571 }
572 self.public_endpoints.push(endpoint);
573 self
574 }
575}
576
577#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
579#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
580#[serde(rename_all = "camelCase")]
581pub enum ContainerStatus {
582 Pending,
584 Running,
586 Stopped,
588 Failing,
591}
592
593#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
595#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
596#[serde(rename_all = "camelCase")]
597pub struct ReplicaStatus {
598 pub replica_id: String,
600 pub ordinal: Option<u32>,
602 pub machine_id: Option<String>,
604 pub healthy: bool,
606 pub container_ip: Option<String>,
608}
609
610#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
617#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
618#[serde(rename_all = "camelCase", deny_unknown_fields)]
619pub struct ContainerTunnel {
620 pub port: u16,
622}
623
624#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
626#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
627#[serde(rename_all = "camelCase")]
628pub struct ContainerOutputs {
629 pub name: String,
631 pub status: ContainerStatus,
633 pub current_replicas: u32,
635 pub desired_replicas: u32,
637 pub internal_dns: String,
639 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
641 pub public_endpoints: HashMap<String, PublicEndpointOutput>,
642 pub replicas: Vec<ReplicaStatus>,
644 #[serde(default, skip_serializing_if = "Vec::is_empty")]
646 pub volumes: Vec<VolumeOutput>,
647 #[serde(default, skip_serializing_if = "Option::is_none")]
649 pub volume_backups: Option<VolumeBackupsStatus>,
650}
651
652#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
654#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
655#[serde(rename_all = "camelCase")]
656pub struct VolumeBackupsStatus {
657 pub state: VolumeBackupsState,
659 #[serde(skip_serializing_if = "Option::is_none")]
661 pub message: Option<String>,
662}
663
664#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
666#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
667#[serde(rename_all = "camelCase")]
668pub enum VolumeBackupsState {
669 Active,
671 Disabled,
673 SetupRequired,
677}
678
679#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
681#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
682#[serde(rename_all = "camelCase")]
683pub struct VolumeOutput {
684 pub ordinal: u32,
686 pub volume_id: String,
688 pub zone: String,
690 #[serde(skip_serializing_if = "Option::is_none")]
692 pub last_snapshot_id: Option<String>,
693 #[serde(skip_serializing_if = "Option::is_none")]
695 pub last_snapshot_at: Option<String>,
696 #[serde(skip_serializing_if = "Option::is_none")]
698 pub last_restore: Option<VolumeRestoreOutput>,
699}
700
701#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
703#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
704#[serde(rename_all = "camelCase")]
705pub struct VolumeRestoreOutput {
706 pub request_id: String,
708 pub snapshot_id: String,
710 pub replaced_volume_snapshot_id: String,
712 pub completed_at: String,
714}
715
716impl ResourceOutputsDefinition for ContainerOutputs {
717 fn get_resource_type(&self) -> ResourceType {
718 Container::RESOURCE_TYPE.clone()
719 }
720
721 fn as_any(&self) -> &dyn Any {
722 self
723 }
724
725 fn box_clone(&self) -> Box<dyn ResourceOutputsDefinition> {
726 Box::new(self.clone())
727 }
728
729 fn outputs_eq(&self, other: &dyn ResourceOutputsDefinition) -> bool {
730 other.as_any().downcast_ref::<ContainerOutputs>() == Some(self)
731 }
732
733 fn to_json_value(&self) -> serde_json::Result<serde_json::Value> {
734 serde_json::to_value(self)
735 }
736}
737
738impl ResourceDefinition for Container {
739 fn get_resource_type(&self) -> ResourceType {
740 Self::RESOURCE_TYPE
741 }
742
743 fn id(&self) -> &str {
744 &self.id
745 }
746
747 fn replace_after_failed_create_is_safe(&self) -> bool {
748 !(self.stateful && self.persistent_storage.is_some())
750 }
751
752 fn get_dependencies(&self) -> Vec<ResourceRef> {
753 let mut deps = self.links.clone();
754 if let Some(cluster) = &self.cluster {
757 deps.push(ResourceRef::new(
758 ComputeCluster::RESOURCE_TYPE.clone(),
759 cluster,
760 ));
761 }
762 deps
763 }
764
765 fn get_permissions(&self) -> Option<&str> {
766 Some(&self.permissions)
767 }
768
769 fn validate_update(&self, new_config: &dyn ResourceDefinition) -> Result<()> {
770 let new_container = new_config
771 .as_any()
772 .downcast_ref::<Container>()
773 .ok_or_else(|| {
774 AlienError::new(ErrorData::UnexpectedResourceType {
775 resource_id: self.id.clone(),
776 expected: Self::RESOURCE_TYPE,
777 actual: new_config.get_resource_type(),
778 })
779 })?;
780
781 new_container.validate_public_endpoints()?;
783
784 if self.id != new_container.id {
785 return Err(AlienError::new(ErrorData::InvalidResourceUpdate {
786 resource_id: self.id.clone(),
787 reason: "the 'id' field is immutable".to_string(),
788 }));
789 }
790
791 if self.cluster != new_container.cluster {
793 return Err(AlienError::new(ErrorData::InvalidResourceUpdate {
794 resource_id: self.id.clone(),
795 reason: "the 'cluster' field is immutable".to_string(),
796 }));
797 }
798
799 if self.stateful != new_container.stateful {
801 return Err(AlienError::new(ErrorData::InvalidResourceUpdate {
802 resource_id: self.id.clone(),
803 reason: "the 'stateful' field is immutable".to_string(),
804 }));
805 }
806
807 if self.ports != new_container.ports {
809 return Err(AlienError::new(ErrorData::InvalidResourceUpdate {
810 resource_id: self.id.clone(),
811 reason: "the 'ports' field is immutable".to_string(),
812 }));
813 }
814
815 if self.public_endpoints != new_container.public_endpoints {
816 return Err(AlienError::new(ErrorData::InvalidResourceUpdate {
817 resource_id: self.id.clone(),
818 reason: "the 'publicEndpoints' field is immutable".to_string(),
819 }));
820 }
821
822 if self.pool != new_container.pool {
824 return Err(AlienError::new(ErrorData::InvalidResourceUpdate {
825 resource_id: self.id.clone(),
826 reason: "the 'pool' field is immutable".to_string(),
827 }));
828 }
829
830 Ok(())
831 }
832
833 fn as_any(&self) -> &dyn Any {
834 self
835 }
836
837 fn as_any_mut(&mut self) -> &mut dyn Any {
838 self
839 }
840
841 fn box_clone(&self) -> Box<dyn ResourceDefinition> {
842 Box::new(self.clone())
843 }
844
845 fn resource_eq(&self, other: &dyn ResourceDefinition) -> bool {
846 other.as_any().downcast_ref::<Container>() == Some(self)
847 }
848
849 fn to_json_value(&self) -> serde_json::Result<serde_json::Value> {
850 serde_json::to_value(self)
851 }
852}
853
854#[cfg(test)]
855mod tests {
856 use super::*;
857 use crate::resources::ExposeProtocol;
858
859 #[test]
860 fn test_container_creation_with_autoscaling() {
861 let container = Container::new("api".to_string())
862 .cluster("compute".to_string())
863 .code(ContainerCode::Image {
864 image: "myapp:latest".to_string(),
865 })
866 .cpu(ResourceSpec {
867 min: "0.5".to_string(),
868 desired: "1".to_string(),
869 })
870 .memory(ResourceSpec {
871 min: "512Mi".to_string(),
872 desired: "1Gi".to_string(),
873 })
874 .port(8080)
875 .public_endpoint(PublicEndpoint {
876 name: "api".to_string(),
877 port: 8080,
878 protocol: ExposeProtocol::Http,
879 host_label: None,
880 wildcard_subdomains: false,
881 })
882 .autoscaling(ContainerAutoscaling {
883 min: 2,
884 desired: 3,
885 max: 10,
886 target_cpu_percent: Some(70.0),
887 target_memory_percent: None,
888 target_http_in_flight_per_replica: Some(100),
889 max_http_p95_latency_ms: None,
890 })
891 .permissions("container-execution".to_string())
892 .build();
893
894 assert_eq!(container.id(), "api");
895 assert_eq!(container.cluster, Some("compute".to_string()));
896 assert!(!container.stateful);
897 assert!(container.autoscaling.is_some());
898 assert_eq!(container.ports.len(), 1);
899 assert_eq!(container.ports[0].port, 8080);
900 }
901
902 #[test]
903 fn container_serializes_stop_grace_period_when_set() {
904 let container = Container::new("api".to_string())
905 .cluster("compute".to_string())
906 .code(ContainerCode::Image {
907 image: "myapp:latest".to_string(),
908 })
909 .cpu(ResourceSpec {
910 min: "0.5".to_string(),
911 desired: "1".to_string(),
912 })
913 .memory(ResourceSpec {
914 min: "512Mi".to_string(),
915 desired: "1Gi".to_string(),
916 })
917 .port(8080)
918 .permissions("container-execution".to_string())
919 .stop_grace_period_seconds(21_600)
920 .build();
921
922 let json = serde_json::to_value(&container).expect("container should serialize");
923 assert_eq!(json["stopGracePeriodSeconds"], 21_600);
924 }
925
926 #[test]
927 fn container_omits_stop_grace_period_when_absent() {
928 let container = Container::new("api".to_string())
929 .cluster("compute".to_string())
930 .code(ContainerCode::Image {
931 image: "myapp:latest".to_string(),
932 })
933 .cpu(ResourceSpec {
934 min: "0.5".to_string(),
935 desired: "1".to_string(),
936 })
937 .memory(ResourceSpec {
938 min: "512Mi".to_string(),
939 desired: "1Gi".to_string(),
940 })
941 .port(8080)
942 .permissions("container-execution".to_string())
943 .build();
944
945 let json = serde_json::to_value(&container).expect("container should serialize");
946 assert!(json.get("stopGracePeriodSeconds").is_none());
947 }
948
949 #[test]
950 fn test_stateful_container_with_storage() {
951 let container = Container::new("postgres".to_string())
952 .cluster("compute".to_string())
953 .code(ContainerCode::Image {
954 image: "postgres:16".to_string(),
955 })
956 .cpu(ResourceSpec {
957 min: "1".to_string(),
958 desired: "2".to_string(),
959 })
960 .memory(ResourceSpec {
961 min: "2Gi".to_string(),
962 desired: "4Gi".to_string(),
963 })
964 .port(5432)
965 .stateful(true)
966 .replicas(1)
967 .persistent_storage(PersistentStorage {
968 size: "100Gi".to_string(),
969 mount_path: "/var/lib/postgresql/data".to_string(),
970 backups: VolumeBackups::default(),
971 })
972 .permissions("database".to_string())
973 .build();
974
975 assert_eq!(container.id(), "postgres");
976 assert!(container.stateful);
977 assert!(container.replicas.is_some());
978 assert!(container.persistent_storage.is_some());
979 }
980
981 #[test]
982 fn test_public_container() {
983 let container = Container::new("frontend".to_string())
984 .cluster("compute".to_string())
985 .code(ContainerCode::Image {
986 image: "frontend:latest".to_string(),
987 })
988 .cpu(ResourceSpec {
989 min: "0.25".to_string(),
990 desired: "0.5".to_string(),
991 })
992 .memory(ResourceSpec {
993 min: "256Mi".to_string(),
994 desired: "512Mi".to_string(),
995 })
996 .port(3000)
997 .public_endpoint(PublicEndpoint {
998 name: "web".to_string(),
999 port: 3000,
1000 protocol: ExposeProtocol::Http,
1001 host_label: None,
1002 wildcard_subdomains: false,
1003 })
1004 .autoscaling(ContainerAutoscaling {
1005 min: 2,
1006 desired: 2,
1007 max: 20,
1008 target_cpu_percent: None,
1009 target_memory_percent: None,
1010 target_http_in_flight_per_replica: Some(50),
1011 max_http_p95_latency_ms: Some(100.0),
1012 })
1013 .health_check(HealthCheck {
1014 path: "/health".to_string(),
1015 port: None,
1016 method: "GET".to_string(),
1017 timeout_seconds: 1,
1018 failure_threshold: 3,
1019 })
1020 .permissions("frontend".to_string())
1021 .build();
1022
1023 assert_eq!(container.ports[0].port, 3000);
1024 assert_eq!(container.public_endpoints[0].name, "web");
1025 assert!(container.health_check.is_some());
1026 }
1027
1028 #[test]
1029 fn test_public_container_endpoint_options() {
1030 let container = Container::new("router".to_string())
1031 .cluster("compute".to_string())
1032 .code(ContainerCode::Image {
1033 image: "router:latest".to_string(),
1034 })
1035 .cpu(ResourceSpec {
1036 min: "0.25".to_string(),
1037 desired: "0.5".to_string(),
1038 })
1039 .memory(ResourceSpec {
1040 min: "256Mi".to_string(),
1041 desired: "512Mi".to_string(),
1042 })
1043 .public_endpoint(PublicEndpoint {
1044 name: "gateway".to_string(),
1045 port: 8080,
1046 protocol: ExposeProtocol::Http,
1047 host_label: Some("gateway".to_string()),
1048 wildcard_subdomains: true,
1049 })
1050 .permissions("router".to_string())
1051 .build();
1052
1053 assert!(container.validate_public_endpoints().is_ok());
1054 assert_eq!(container.ports.len(), 1);
1055 assert_eq!(container.public_endpoints.len(), 1);
1056 assert_eq!(
1057 container.public_endpoints[0].host_label.as_deref(),
1058 Some("gateway")
1059 );
1060 assert!(container.public_endpoints[0].wildcard_subdomains);
1061 }
1062
1063 #[test]
1064 fn test_public_container_rejects_invalid_host_label() {
1065 let container = Container::new("router".to_string())
1066 .cluster("compute".to_string())
1067 .code(ContainerCode::Image {
1068 image: "router:latest".to_string(),
1069 })
1070 .cpu(ResourceSpec {
1071 min: "0.25".to_string(),
1072 desired: "0.5".to_string(),
1073 })
1074 .memory(ResourceSpec {
1075 min: "256Mi".to_string(),
1076 desired: "512Mi".to_string(),
1077 })
1078 .public_endpoint(PublicEndpoint {
1079 name: "gateway".to_string(),
1080 port: 8080,
1081 protocol: ExposeProtocol::Http,
1082 host_label: Some("bad.label".to_string()),
1083 wildcard_subdomains: true,
1084 })
1085 .permissions("router".to_string())
1086 .build();
1087
1088 assert!(container.validate_public_endpoints().is_err());
1089 }
1090
1091 #[test]
1092 fn test_container_with_links() {
1093 use crate::Storage;
1094
1095 let storage = Storage::new("data".to_string()).build();
1096
1097 let container = Container::new("worker".to_string())
1098 .cluster("compute".to_string())
1099 .code(ContainerCode::Image {
1100 image: "worker:latest".to_string(),
1101 })
1102 .cpu(ResourceSpec {
1103 min: "0.5".to_string(),
1104 desired: "1".to_string(),
1105 })
1106 .memory(ResourceSpec {
1107 min: "512Mi".to_string(),
1108 desired: "1Gi".to_string(),
1109 })
1110 .port(8080)
1111 .replicas(3)
1112 .link(&storage)
1113 .permissions("worker".to_string())
1114 .build();
1115
1116 let deps = container.get_dependencies();
1118 assert_eq!(deps.len(), 2);
1119 }
1120
1121 #[test]
1122 fn test_container_validate_update_immutable_cluster() {
1123 let container1 = Container::new("api".to_string())
1124 .cluster("cluster-1".to_string())
1125 .code(ContainerCode::Image {
1126 image: "myapp:v1".to_string(),
1127 })
1128 .cpu(ResourceSpec {
1129 min: "0.5".to_string(),
1130 desired: "1".to_string(),
1131 })
1132 .memory(ResourceSpec {
1133 min: "512Mi".to_string(),
1134 desired: "1Gi".to_string(),
1135 })
1136 .port(8080)
1137 .replicas(2)
1138 .permissions("execution".to_string())
1139 .build();
1140
1141 let container2 = Container::new("api".to_string())
1142 .cluster("cluster-2".to_string()) .code(ContainerCode::Image {
1144 image: "myapp:v2".to_string(),
1145 })
1146 .cpu(ResourceSpec {
1147 min: "0.5".to_string(),
1148 desired: "1".to_string(),
1149 })
1150 .memory(ResourceSpec {
1151 min: "512Mi".to_string(),
1152 desired: "1Gi".to_string(),
1153 })
1154 .port(8080)
1155 .replicas(2)
1156 .permissions("execution".to_string())
1157 .build();
1158
1159 let result = container1.validate_update(&container2);
1160 assert!(result.is_err());
1161 }
1162
1163 #[test]
1164 fn test_container_validate_update_allowed_changes() {
1165 let container1 = Container::new("api".to_string())
1166 .cluster("compute".to_string())
1167 .code(ContainerCode::Image {
1168 image: "myapp:v1".to_string(),
1169 })
1170 .cpu(ResourceSpec {
1171 min: "0.5".to_string(),
1172 desired: "1".to_string(),
1173 })
1174 .memory(ResourceSpec {
1175 min: "512Mi".to_string(),
1176 desired: "1Gi".to_string(),
1177 })
1178 .port(8080)
1179 .replicas(2)
1180 .permissions("execution".to_string())
1181 .build();
1182
1183 let container2 = Container::new("api".to_string())
1184 .cluster("compute".to_string())
1185 .code(ContainerCode::Image {
1186 image: "myapp:v2".to_string(), })
1188 .cpu(ResourceSpec {
1189 min: "1".to_string(), desired: "2".to_string(),
1191 })
1192 .memory(ResourceSpec {
1193 min: "1Gi".to_string(),
1194 desired: "2Gi".to_string(),
1195 })
1196 .port(8080)
1197 .replicas(5) .permissions("execution".to_string())
1199 .build();
1200
1201 let result = container1.validate_update(&container2);
1202 assert!(result.is_ok());
1203 }
1204
1205 #[test]
1206 fn test_container_serialization() {
1207 let container = Container::new("test".to_string())
1208 .cluster("compute".to_string())
1209 .code(ContainerCode::Image {
1210 image: "test:latest".to_string(),
1211 })
1212 .cpu(ResourceSpec {
1213 min: "0.5".to_string(),
1214 desired: "1".to_string(),
1215 })
1216 .memory(ResourceSpec {
1217 min: "512Mi".to_string(),
1218 desired: "1Gi".to_string(),
1219 })
1220 .port(8080)
1221 .replicas(1)
1222 .permissions("test".to_string())
1223 .build();
1224
1225 let json = serde_json::to_string(&container).unwrap();
1226 let deserialized: Container = serde_json::from_str(&json).unwrap();
1227 assert_eq!(container, deserialized);
1228 }
1229
1230 #[test]
1231 fn test_container_multi_endpoint_validation() {
1232 let container = Container::new("multi-tcp".to_string())
1233 .cluster("compute".to_string())
1234 .code(ContainerCode::Image {
1235 image: "test:latest".to_string(),
1236 })
1237 .cpu(ResourceSpec {
1238 min: "1".to_string(),
1239 desired: "1".to_string(),
1240 })
1241 .memory(ResourceSpec {
1242 min: "1Gi".to_string(),
1243 desired: "1Gi".to_string(),
1244 })
1245 .port(8080)
1246 .public_endpoint(PublicEndpoint {
1247 name: "api".to_string(),
1248 port: 8080,
1249 protocol: ExposeProtocol::Http,
1250 host_label: None,
1251 wildcard_subdomains: false,
1252 })
1253 .public_endpoint(PublicEndpoint {
1254 name: "wildcard".to_string(),
1255 port: 8080,
1256 protocol: ExposeProtocol::Http,
1257 host_label: Some("wildcard".to_string()),
1258 wildcard_subdomains: true,
1259 })
1260 .replicas(1)
1261 .permissions("test".to_string())
1262 .build();
1263
1264 assert!(container.validate_public_endpoints().is_ok());
1265
1266 let invalid_container = Container::new("multi-http".to_string())
1267 .cluster("compute".to_string())
1268 .code(ContainerCode::Image {
1269 image: "test:latest".to_string(),
1270 })
1271 .cpu(ResourceSpec {
1272 min: "1".to_string(),
1273 desired: "1".to_string(),
1274 })
1275 .memory(ResourceSpec {
1276 min: "1Gi".to_string(),
1277 desired: "1Gi".to_string(),
1278 })
1279 .port(8080)
1280 .port(9090)
1281 .public_endpoint(PublicEndpoint {
1282 name: "api".to_string(),
1283 port: 8080,
1284 protocol: ExposeProtocol::Http,
1285 host_label: None,
1286 wildcard_subdomains: false,
1287 })
1288 .public_endpoint(PublicEndpoint {
1289 name: "admin".to_string(),
1290 port: 9090,
1291 protocol: ExposeProtocol::Http,
1292 host_label: None,
1293 wildcard_subdomains: false,
1294 })
1295 .replicas(1)
1296 .permissions("test".to_string())
1297 .build();
1298
1299 assert!(invalid_container.validate_public_endpoints().is_err());
1300 }
1301
1302 #[test]
1303 fn container_rejects_multiple_apex_public_endpoints() {
1304 let container = Container::new("apex-container".to_string())
1305 .cluster("compute".to_string())
1306 .code(ContainerCode::Image {
1307 image: "test:latest".to_string(),
1308 })
1309 .cpu(ResourceSpec {
1310 min: "1".to_string(),
1311 desired: "1".to_string(),
1312 })
1313 .memory(ResourceSpec {
1314 min: "1Gi".to_string(),
1315 desired: "1Gi".to_string(),
1316 })
1317 .port(8080)
1318 .public_endpoint(PublicEndpoint {
1319 name: "web".to_string(),
1320 port: 8080,
1321 protocol: ExposeProtocol::Http,
1322 host_label: Some(APEX_HOST_LABEL.to_string()),
1323 wildcard_subdomains: false,
1324 })
1325 .public_endpoint(PublicEndpoint {
1326 name: "admin".to_string(),
1327 port: 8080,
1328 protocol: ExposeProtocol::Http,
1329 host_label: Some(APEX_HOST_LABEL.to_string()),
1330 wildcard_subdomains: false,
1331 })
1332 .replicas(1)
1333 .permissions("test".to_string())
1334 .build();
1335
1336 assert!(container.validate_public_endpoints().is_err());
1337 }
1338
1339 #[test]
1340 fn test_container_empty_ports_validation() {
1341 let container = Container::new("no-ports".to_string())
1342 .cluster("compute".to_string())
1343 .code(ContainerCode::Image {
1344 image: "test:latest".to_string(),
1345 })
1346 .cpu(ResourceSpec {
1347 min: "1".to_string(),
1348 desired: "1".to_string(),
1349 })
1350 .memory(ResourceSpec {
1351 min: "1Gi".to_string(),
1352 desired: "1Gi".to_string(),
1353 })
1354 .replicas(1)
1355 .permissions("test".to_string())
1356 .build();
1357
1358 assert!(container.validate_public_endpoints().is_ok());
1359 }
1360
1361 #[test]
1362 fn test_container_commands_enabled_defaults_false() {
1363 let container = Container::new("no-commands".to_string())
1364 .cluster("compute".to_string())
1365 .code(ContainerCode::Image {
1366 image: "test:latest".to_string(),
1367 })
1368 .cpu(ResourceSpec {
1369 min: "0.5".to_string(),
1370 desired: "1".to_string(),
1371 })
1372 .memory(ResourceSpec {
1373 min: "512Mi".to_string(),
1374 desired: "1Gi".to_string(),
1375 })
1376 .port(8080)
1377 .permissions("test".to_string())
1378 .build();
1379
1380 assert!(!container.commands_enabled);
1381 }
1382
1383 #[test]
1384 fn test_container_commands_enabled_builder() {
1385 let container = Container::new("cmd-container".to_string())
1386 .cluster("compute".to_string())
1387 .code(ContainerCode::Image {
1388 image: "test:latest".to_string(),
1389 })
1390 .cpu(ResourceSpec {
1391 min: "0.5".to_string(),
1392 desired: "1".to_string(),
1393 })
1394 .memory(ResourceSpec {
1395 min: "512Mi".to_string(),
1396 desired: "1Gi".to_string(),
1397 })
1398 .port(8080)
1399 .permissions("test".to_string())
1400 .commands_enabled(true)
1401 .build();
1402
1403 assert!(container.commands_enabled);
1404 }
1405
1406 #[test]
1407 fn test_container_commands_enabled_serializes_camel_case() {
1408 let container = Container::new("cmd-container".to_string())
1409 .cluster("compute".to_string())
1410 .code(ContainerCode::Image {
1411 image: "test:latest".to_string(),
1412 })
1413 .cpu(ResourceSpec {
1414 min: "0.5".to_string(),
1415 desired: "1".to_string(),
1416 })
1417 .memory(ResourceSpec {
1418 min: "512Mi".to_string(),
1419 desired: "1Gi".to_string(),
1420 })
1421 .port(8080)
1422 .permissions("test".to_string())
1423 .commands_enabled(true)
1424 .build();
1425
1426 let json = serde_json::to_value(&container).expect("container should serialize");
1427 assert_eq!(json["commandsEnabled"], true);
1428
1429 let deserialized: Container =
1430 serde_json::from_value(json).expect("container should deserialize");
1431 assert_eq!(deserialized, container);
1432 }
1433}