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", deny_unknown_fields)]
62pub struct ResourceChoiceRange {
63 pub min: String,
65 pub max: String,
67 pub default: String,
69}
70
71#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
73#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
74#[serde(rename_all = "camelCase", deny_unknown_fields)]
75pub struct ContainerResourceChoices {
76 #[serde(default, skip_serializing_if = "Option::is_none")]
78 pub cpu: Option<ResourceChoiceRange>,
79 #[serde(default, skip_serializing_if = "Option::is_none")]
81 pub memory: Option<ResourceChoiceRange>,
82}
83
84#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
86#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
87#[serde(rename_all = "camelCase")]
88pub struct ContainerGpuSpec {
89 #[serde(rename = "type")]
91 pub gpu_type: String,
92 pub count: u32,
94}
95
96#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
98#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
99#[serde(rename_all = "camelCase")]
100pub struct PersistentStorage {
101 pub size: String,
103 pub mount_path: String,
105 #[serde(default, skip_serializing_if = "Option::is_none")]
107 pub encryption_key: Option<ResourceRef>,
108 #[serde(default)]
110 pub backups: VolumeBackups,
111}
112
113pub const VOLUME_BACKUP_INTERVAL_HOURS: [u32; 7] = [1, 2, 4, 6, 8, 12, 24];
115
116pub const VOLUME_BACKUP_MAX_SNAPSHOTS: u32 = 450;
120
121pub const VOLUME_BACKUP_MAX_RETENTION_DAYS: u32 = 365;
124
125#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
137#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
138#[serde(rename_all = "camelCase")]
139pub struct VolumeBackups {
140 #[serde(default = "default_volume_backups_enabled")]
142 pub enabled: bool,
143 #[serde(default = "default_volume_backup_interval_hours")]
145 pub interval_hours: u32,
146 #[serde(default = "default_volume_backup_retention_days")]
148 pub retention_days: u32,
149}
150
151fn default_volume_backups_enabled() -> bool {
152 true
153}
154
155fn default_volume_backup_interval_hours() -> u32 {
156 24
157}
158
159fn default_volume_backup_retention_days() -> u32 {
160 7
161}
162
163impl Default for VolumeBackups {
164 fn default() -> Self {
165 Self {
166 enabled: default_volume_backups_enabled(),
167 interval_hours: default_volume_backup_interval_hours(),
168 retention_days: default_volume_backup_retention_days(),
169 }
170 }
171}
172
173impl VolumeBackups {
174 pub fn disabled() -> Self {
176 Self {
177 enabled: false,
178 ..Self::default()
179 }
180 }
181
182 pub fn validation_error(&self) -> Option<String> {
184 if !self.enabled {
185 return None;
186 }
187 if !VOLUME_BACKUP_INTERVAL_HOURS.contains(&self.interval_hours) {
188 return Some(format!(
189 "backup intervalHours must be one of {VOLUME_BACKUP_INTERVAL_HOURS:?}, got {}",
190 self.interval_hours
191 ));
192 }
193 if self.retention_days == 0 {
194 return Some("backup retentionDays must be at least 1".to_string());
195 }
196 if self.retention_days > VOLUME_BACKUP_MAX_RETENTION_DAYS {
197 return Some(format!(
198 "backup retentionDays must be at most {VOLUME_BACKUP_MAX_RETENTION_DAYS}, got {}",
199 self.retention_days
200 ));
201 }
202 let snapshots = self.retention_days * 24 / self.interval_hours;
203 if snapshots > VOLUME_BACKUP_MAX_SNAPSHOTS {
204 return Some(format!(
205 "backups every {} hours for {} days keep {snapshots} snapshots per volume; \
206 the most is {VOLUME_BACKUP_MAX_SNAPSHOTS}",
207 self.interval_hours, self.retention_days
208 ));
209 }
210 None
211 }
212}
213
214#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
217#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
218#[serde(rename_all = "camelCase")]
219pub struct KubernetesSecretMount {
220 pub secret_name: String,
222 pub mount_path: String,
224}
225
226#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
228#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
229#[serde(rename_all = "camelCase")]
230pub struct KubernetesHttpProbe {
231 pub path: String,
233 pub port: u16,
235}
236
237#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
239#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
240#[serde(rename_all = "camelCase")]
241pub enum ContainerSecurityProfile {
242 Restricted,
243}
244
245#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
247#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
248#[serde(rename_all = "camelCase")]
249pub struct ContainerSecurity {
250 pub profile: ContainerSecurityProfile,
252 pub run_as_user: i64,
254 pub run_as_group: i64,
256 pub read_only_root_filesystem: bool,
258}
259
260#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
262#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
263#[serde(rename_all = "camelCase")]
264pub struct ContainerAutoscaling {
265 pub min: u32,
267 pub desired: u32,
269 pub max: u32,
271 #[serde(skip_serializing_if = "Option::is_none")]
273 pub target_cpu_percent: Option<f64>,
274 #[serde(skip_serializing_if = "Option::is_none")]
276 pub target_memory_percent: Option<f64>,
277 #[serde(skip_serializing_if = "Option::is_none")]
279 pub target_http_in_flight_per_replica: Option<u32>,
280 #[serde(skip_serializing_if = "Option::is_none")]
282 pub max_http_p95_latency_ms: Option<f64>,
283}
284
285#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
287#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
288#[serde(rename_all = "camelCase")]
289pub struct HealthCheck {
290 #[serde(default = "default_health_path")]
292 pub path: String,
293 #[serde(skip_serializing_if = "Option::is_none")]
295 pub port: Option<u16>,
296 #[serde(default = "default_health_method")]
298 pub method: String,
299 #[serde(default = "default_timeout_seconds")]
301 pub timeout_seconds: u32,
302 #[serde(default = "default_failure_threshold")]
304 pub failure_threshold: u32,
305}
306
307fn default_health_path() -> String {
308 "/health".to_string()
309}
310
311fn default_health_method() -> String {
312 "GET".to_string()
313}
314
315fn default_timeout_seconds() -> u32 {
316 1
317}
318
319fn default_failure_threshold() -> u32 {
320 3
321}
322
323#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
325#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
326#[serde(rename_all = "camelCase")]
327pub struct ContainerPort {
328 pub port: u16,
330}
331
332#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Builder)]
371#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
372#[serde(rename_all = "camelCase", deny_unknown_fields)]
373#[builder(start_fn = new)]
374pub struct Container {
375 #[builder(start_fn)]
378 pub id: String,
379
380 #[builder(field)]
382 pub links: Vec<ResourceRef>,
383
384 #[builder(field)]
386 pub ports: Vec<ContainerPort>,
387
388 #[builder(field)]
390 #[serde(default, skip_serializing_if = "Vec::is_empty")]
391 pub kubernetes_secret_mounts: Vec<KubernetesSecretMount>,
392
393 #[builder(field)]
395 #[serde(default, skip_serializing_if = "Vec::is_empty")]
396 pub public_endpoints: Vec<PublicEndpoint>,
397
398 #[serde(default, skip_serializing_if = "Option::is_none")]
402 pub tunnel: Option<ContainerTunnel>,
403
404 #[serde(skip_serializing_if = "Option::is_none")]
406 pub kubernetes_liveness_probe: Option<KubernetesHttpProbe>,
407
408 #[serde(skip_serializing_if = "Option::is_none")]
410 pub kubernetes_readiness_probe: Option<KubernetesHttpProbe>,
411
412 #[serde(skip_serializing_if = "Option::is_none")]
414 pub security: Option<ContainerSecurity>,
415
416 #[serde(skip_serializing_if = "Option::is_none")]
419 pub cluster: Option<String>,
420
421 pub code: ContainerCode,
423
424 pub cpu: ResourceSpec,
426
427 pub memory: ResourceSpec,
429
430 #[serde(default, skip_serializing_if = "Option::is_none")]
432 pub resource_choices: Option<ContainerResourceChoices>,
433
434 #[serde(skip_serializing_if = "Option::is_none")]
436 pub gpu: Option<ContainerGpuSpec>,
437
438 #[serde(skip_serializing_if = "Option::is_none")]
440 pub ephemeral_storage: Option<String>,
441
442 #[serde(skip_serializing_if = "Option::is_none")]
444 pub persistent_storage: Option<PersistentStorage>,
445
446 #[serde(skip_serializing_if = "Option::is_none")]
448 pub replicas: Option<u32>,
449
450 #[serde(skip_serializing_if = "Option::is_none")]
452 pub autoscaling: Option<ContainerAutoscaling>,
453
454 #[builder(default = false)]
456 #[serde(default)]
457 pub stateful: bool,
458
459 #[builder(default)]
461 #[serde(default)]
462 pub environment: HashMap<String, String>,
463
464 #[serde(skip_serializing_if = "Option::is_none")]
467 pub pool: Option<String>,
468
469 pub permissions: String,
471
472 #[serde(skip_serializing_if = "Option::is_none")]
474 pub health_check: Option<HealthCheck>,
475
476 #[serde(skip_serializing_if = "Option::is_none")]
478 pub command: Option<Vec<String>>,
479
480 #[builder(default = default_commands_enabled())]
484 #[serde(default = "default_commands_enabled")]
485 #[cfg_attr(feature = "openapi", schema(default = default_commands_enabled))]
486 pub commands_enabled: bool,
487
488 #[serde(skip_serializing_if = "Option::is_none")]
493 #[cfg_attr(feature = "openapi", schema(minimum = 1, maximum = 86400))]
494 pub stop_grace_period_seconds: Option<u32>,
495}
496
497impl Container {
498 pub const RESOURCE_TYPE: ResourceType = ResourceType::from_static("container");
500
501 pub fn id(&self) -> &str {
503 &self.id
504 }
505
506 pub fn get_permissions(&self) -> &str {
508 &self.permissions
509 }
510
511 pub fn is_stateless(&self) -> bool {
513 !self.stateful
514 }
515
516 fn validate_public_endpoints(&self) -> Result<()> {
518 let mut endpoint_names = std::collections::HashSet::new();
519 let mut backend_ports = std::collections::HashSet::new();
520 let mut apex_endpoint_name: Option<&str> = None;
521
522 for endpoint in &self.public_endpoints {
523 endpoint.validate_for_resource(&self.id)?;
524
525 if !endpoint_names.insert(endpoint.name.as_str()) {
526 return Err(AlienError::new(ErrorData::InvalidResourceUpdate {
527 resource_id: self.id.clone(),
528 reason: format!("duplicate public endpoint name '{}'", endpoint.name),
529 }));
530 }
531
532 if endpoint.host_label.as_deref() == Some(APEX_HOST_LABEL) {
533 if let Some(existing_name) = apex_endpoint_name {
534 return Err(AlienError::new(ErrorData::InvalidResourceUpdate {
535 resource_id: self.id.clone(),
536 reason: format!(
537 "only one apex public endpoint is allowed per resource; '{}' already uses hostLabel '@'",
538 existing_name
539 ),
540 }));
541 }
542 apex_endpoint_name = Some(endpoint.name.as_str());
543 }
544
545 backend_ports.insert(endpoint.port);
546
547 if !self.ports.iter().any(|port| port.port == endpoint.port) {
548 return Err(AlienError::new(ErrorData::InvalidResourceUpdate {
549 resource_id: self.id.clone(),
550 reason: format!(
551 "public endpoint '{}' references undeclared port {}",
552 endpoint.name, endpoint.port
553 ),
554 }));
555 }
556 }
557
558 if backend_ports.len() > 1 {
559 return Err(AlienError::new(ErrorData::InvalidResourceUpdate {
560 resource_id: self.id.clone(),
561 reason:
562 "public endpoints on one container must currently route to the same backend port"
563 .to_string(),
564 }));
565 }
566
567 Ok(())
568 }
569}
570
571fn default_commands_enabled() -> bool {
572 false
573}
574
575impl<S: container_builder::State> ContainerBuilder<S> {
576 pub fn link<R: ?Sized>(mut self, resource: &R) -> Self
578 where
579 for<'a> &'a R: Into<ResourceRef>,
580 {
581 let resource_ref: ResourceRef = resource.into();
582 self.links.push(resource_ref);
583 self
584 }
585
586 pub fn port(mut self, port: u16) -> Self {
588 self.ports.push(ContainerPort { port });
589 self
590 }
591
592 pub fn kubernetes_secret_mount(mut self, mount: KubernetesSecretMount) -> Self {
594 self.kubernetes_secret_mounts.push(mount);
595 self
596 }
597
598 pub fn public_endpoint(mut self, endpoint: PublicEndpoint) -> Self {
600 if !self.ports.iter().any(|p| p.port == endpoint.port) {
601 self.ports.push(ContainerPort {
602 port: endpoint.port,
603 });
604 }
605 self.public_endpoints.push(endpoint);
606 self
607 }
608}
609
610#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
612#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
613#[serde(rename_all = "camelCase")]
614pub enum ContainerStatus {
615 Pending,
617 Running,
619 Stopped,
621 Failing,
624}
625
626#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
628#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
629#[serde(rename_all = "camelCase")]
630pub struct ReplicaStatus {
631 pub replica_id: String,
633 pub ordinal: Option<u32>,
635 pub machine_id: Option<String>,
637 pub healthy: bool,
639 pub container_ip: Option<String>,
641}
642
643#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
650#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
651#[serde(rename_all = "camelCase", deny_unknown_fields)]
652pub struct ContainerTunnel {
653 pub port: u16,
655}
656
657#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
659#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
660#[serde(rename_all = "camelCase")]
661pub struct ContainerOutputs {
662 pub name: String,
664 pub status: ContainerStatus,
666 pub current_replicas: u32,
668 pub desired_replicas: u32,
670 pub internal_dns: String,
672 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
674 pub public_endpoints: HashMap<String, PublicEndpointOutput>,
675 pub replicas: Vec<ReplicaStatus>,
677 #[serde(default, skip_serializing_if = "Vec::is_empty")]
679 pub volumes: Vec<VolumeOutput>,
680 #[serde(default, skip_serializing_if = "Option::is_none")]
682 pub volume_backups: Option<VolumeBackupsStatus>,
683}
684
685#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
687#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
688#[serde(rename_all = "camelCase")]
689pub struct VolumeBackupsStatus {
690 pub state: VolumeBackupsState,
692 #[serde(skip_serializing_if = "Option::is_none")]
694 pub message: Option<String>,
695}
696
697#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
699#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
700#[serde(rename_all = "camelCase")]
701pub enum VolumeBackupsState {
702 Active,
704 Disabled,
706 SetupRequired,
710}
711
712#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
714#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
715#[serde(rename_all = "camelCase")]
716pub struct VolumeOutput {
717 pub ordinal: u32,
719 pub volume_id: String,
721 pub zone: String,
723 #[serde(skip_serializing_if = "Option::is_none")]
725 pub last_snapshot_id: Option<String>,
726 #[serde(skip_serializing_if = "Option::is_none")]
728 pub last_snapshot_at: Option<String>,
729 #[serde(skip_serializing_if = "Option::is_none")]
731 pub last_restore: Option<VolumeRestoreOutput>,
732}
733
734#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
736#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
737#[serde(rename_all = "camelCase")]
738pub struct VolumeRestoreOutput {
739 pub request_id: String,
741 pub snapshot_id: String,
743 pub replaced_volume_snapshot_id: String,
745 pub completed_at: String,
747}
748
749impl ResourceOutputsDefinition for ContainerOutputs {
750 fn get_resource_type(&self) -> ResourceType {
751 Container::RESOURCE_TYPE.clone()
752 }
753
754 fn as_any(&self) -> &dyn Any {
755 self
756 }
757
758 fn box_clone(&self) -> Box<dyn ResourceOutputsDefinition> {
759 Box::new(self.clone())
760 }
761
762 fn outputs_eq(&self, other: &dyn ResourceOutputsDefinition) -> bool {
763 other.as_any().downcast_ref::<ContainerOutputs>() == Some(self)
764 }
765
766 fn to_json_value(&self) -> serde_json::Result<serde_json::Value> {
767 serde_json::to_value(self)
768 }
769}
770
771impl ResourceDefinition for Container {
772 fn get_resource_type(&self) -> ResourceType {
773 Self::RESOURCE_TYPE
774 }
775
776 fn id(&self) -> &str {
777 &self.id
778 }
779
780 fn replace_after_failed_create_is_safe(&self) -> bool {
781 !(self.stateful && self.persistent_storage.is_some())
783 }
784
785 fn get_dependencies(&self) -> Vec<ResourceRef> {
786 let mut deps = self.links.clone();
787 if let Some(key) = self
788 .persistent_storage
789 .as_ref()
790 .and_then(|storage| storage.encryption_key.as_ref())
791 {
792 deps.push(key.clone());
793 }
794 if let Some(cluster) = &self.cluster {
797 deps.push(ResourceRef::new(
798 ComputeCluster::RESOURCE_TYPE.clone(),
799 cluster,
800 ));
801 }
802 deps
803 }
804
805 fn get_permissions(&self) -> Option<&str> {
806 Some(&self.permissions)
807 }
808
809 fn validate_update(&self, new_config: &dyn ResourceDefinition) -> Result<()> {
810 let new_container = new_config
811 .as_any()
812 .downcast_ref::<Container>()
813 .ok_or_else(|| {
814 AlienError::new(ErrorData::UnexpectedResourceType {
815 resource_id: self.id.clone(),
816 expected: Self::RESOURCE_TYPE,
817 actual: new_config.get_resource_type(),
818 })
819 })?;
820
821 new_container.validate_public_endpoints()?;
823
824 if self.id != new_container.id {
825 return Err(AlienError::new(ErrorData::InvalidResourceUpdate {
826 resource_id: self.id.clone(),
827 reason: "the 'id' field is immutable".to_string(),
828 }));
829 }
830
831 if self.cluster != new_container.cluster {
833 return Err(AlienError::new(ErrorData::InvalidResourceUpdate {
834 resource_id: self.id.clone(),
835 reason: "the 'cluster' field is immutable".to_string(),
836 }));
837 }
838
839 if self.stateful != new_container.stateful {
841 return Err(AlienError::new(ErrorData::InvalidResourceUpdate {
842 resource_id: self.id.clone(),
843 reason: "the 'stateful' field is immutable".to_string(),
844 }));
845 }
846
847 let old_key = self
848 .persistent_storage
849 .as_ref()
850 .and_then(|storage| storage.encryption_key.as_ref());
851 let new_key = new_container
852 .persistent_storage
853 .as_ref()
854 .and_then(|storage| storage.encryption_key.as_ref());
855 if old_key != new_key {
856 return Err(AlienError::new(ErrorData::InvalidResourceUpdate {
857 resource_id: self.id.clone(),
858 reason: "the 'persistentStorage.encryptionKey' field is immutable once the resource exists".to_string(),
859 }));
860 }
861
862 if self.ports != new_container.ports {
864 return Err(AlienError::new(ErrorData::InvalidResourceUpdate {
865 resource_id: self.id.clone(),
866 reason: "the 'ports' field is immutable".to_string(),
867 }));
868 }
869
870 if self.public_endpoints != new_container.public_endpoints {
871 return Err(AlienError::new(ErrorData::InvalidResourceUpdate {
872 resource_id: self.id.clone(),
873 reason: "the 'publicEndpoints' field is immutable".to_string(),
874 }));
875 }
876
877 if self.pool != new_container.pool {
879 return Err(AlienError::new(ErrorData::InvalidResourceUpdate {
880 resource_id: self.id.clone(),
881 reason: "the 'pool' field is immutable".to_string(),
882 }));
883 }
884
885 Ok(())
886 }
887
888 fn as_any(&self) -> &dyn Any {
889 self
890 }
891
892 fn as_any_mut(&mut self) -> &mut dyn Any {
893 self
894 }
895
896 fn box_clone(&self) -> Box<dyn ResourceDefinition> {
897 Box::new(self.clone())
898 }
899
900 fn resource_eq(&self, other: &dyn ResourceDefinition) -> bool {
901 other.as_any().downcast_ref::<Container>() == Some(self)
902 }
903
904 fn to_json_value(&self) -> serde_json::Result<serde_json::Value> {
905 serde_json::to_value(self)
906 }
907}
908
909#[cfg(test)]
910mod tests {
911 use super::*;
912 use crate::resources::ExposeProtocol;
913
914 #[test]
915 fn test_container_creation_with_autoscaling() {
916 let container = Container::new("api".to_string())
917 .cluster("compute".to_string())
918 .code(ContainerCode::Image {
919 image: "myapp:latest".to_string(),
920 })
921 .cpu(ResourceSpec {
922 min: "0.5".to_string(),
923 desired: "1".to_string(),
924 })
925 .memory(ResourceSpec {
926 min: "512Mi".to_string(),
927 desired: "1Gi".to_string(),
928 })
929 .port(8080)
930 .public_endpoint(PublicEndpoint {
931 name: "api".to_string(),
932 port: 8080,
933 protocol: ExposeProtocol::Http,
934 host_label: None,
935 wildcard_subdomains: false,
936 })
937 .autoscaling(ContainerAutoscaling {
938 min: 2,
939 desired: 3,
940 max: 10,
941 target_cpu_percent: Some(70.0),
942 target_memory_percent: None,
943 target_http_in_flight_per_replica: Some(100),
944 max_http_p95_latency_ms: None,
945 })
946 .permissions("container-execution".to_string())
947 .build();
948
949 assert_eq!(container.id(), "api");
950 assert_eq!(container.cluster, Some("compute".to_string()));
951 assert!(!container.stateful);
952 assert!(container.autoscaling.is_some());
953 assert_eq!(container.ports.len(), 1);
954 assert_eq!(container.ports[0].port, 8080);
955 }
956
957 #[test]
958 fn container_serializes_stop_grace_period_when_set() {
959 let container = Container::new("api".to_string())
960 .cluster("compute".to_string())
961 .code(ContainerCode::Image {
962 image: "myapp:latest".to_string(),
963 })
964 .cpu(ResourceSpec {
965 min: "0.5".to_string(),
966 desired: "1".to_string(),
967 })
968 .memory(ResourceSpec {
969 min: "512Mi".to_string(),
970 desired: "1Gi".to_string(),
971 })
972 .port(8080)
973 .permissions("container-execution".to_string())
974 .stop_grace_period_seconds(21_600)
975 .build();
976
977 let json = serde_json::to_value(&container).expect("container should serialize");
978 assert_eq!(json["stopGracePeriodSeconds"], 21_600);
979 }
980
981 #[test]
982 fn container_omits_stop_grace_period_when_absent() {
983 let container = Container::new("api".to_string())
984 .cluster("compute".to_string())
985 .code(ContainerCode::Image {
986 image: "myapp:latest".to_string(),
987 })
988 .cpu(ResourceSpec {
989 min: "0.5".to_string(),
990 desired: "1".to_string(),
991 })
992 .memory(ResourceSpec {
993 min: "512Mi".to_string(),
994 desired: "1Gi".to_string(),
995 })
996 .port(8080)
997 .permissions("container-execution".to_string())
998 .build();
999
1000 let json = serde_json::to_value(&container).expect("container should serialize");
1001 assert!(json.get("stopGracePeriodSeconds").is_none());
1002 }
1003
1004 #[test]
1005 fn test_stateful_container_with_storage() {
1006 let container = Container::new("postgres".to_string())
1007 .cluster("compute".to_string())
1008 .code(ContainerCode::Image {
1009 image: "postgres:16".to_string(),
1010 })
1011 .cpu(ResourceSpec {
1012 min: "1".to_string(),
1013 desired: "2".to_string(),
1014 })
1015 .memory(ResourceSpec {
1016 min: "2Gi".to_string(),
1017 desired: "4Gi".to_string(),
1018 })
1019 .port(5432)
1020 .stateful(true)
1021 .replicas(1)
1022 .persistent_storage(PersistentStorage {
1023 encryption_key: None,
1024 size: "100Gi".to_string(),
1025 mount_path: "/var/lib/postgresql/data".to_string(),
1026 backups: VolumeBackups::default(),
1027 })
1028 .permissions("database".to_string())
1029 .build();
1030
1031 assert_eq!(container.id(), "postgres");
1032 assert!(container.stateful);
1033 assert!(container.replicas.is_some());
1034 assert!(container.persistent_storage.is_some());
1035 }
1036
1037 #[test]
1038 fn test_public_container() {
1039 let container = Container::new("frontend".to_string())
1040 .cluster("compute".to_string())
1041 .code(ContainerCode::Image {
1042 image: "frontend:latest".to_string(),
1043 })
1044 .cpu(ResourceSpec {
1045 min: "0.25".to_string(),
1046 desired: "0.5".to_string(),
1047 })
1048 .memory(ResourceSpec {
1049 min: "256Mi".to_string(),
1050 desired: "512Mi".to_string(),
1051 })
1052 .port(3000)
1053 .public_endpoint(PublicEndpoint {
1054 name: "web".to_string(),
1055 port: 3000,
1056 protocol: ExposeProtocol::Http,
1057 host_label: None,
1058 wildcard_subdomains: false,
1059 })
1060 .autoscaling(ContainerAutoscaling {
1061 min: 2,
1062 desired: 2,
1063 max: 20,
1064 target_cpu_percent: None,
1065 target_memory_percent: None,
1066 target_http_in_flight_per_replica: Some(50),
1067 max_http_p95_latency_ms: Some(100.0),
1068 })
1069 .health_check(HealthCheck {
1070 path: "/health".to_string(),
1071 port: None,
1072 method: "GET".to_string(),
1073 timeout_seconds: 1,
1074 failure_threshold: 3,
1075 })
1076 .permissions("frontend".to_string())
1077 .build();
1078
1079 assert_eq!(container.ports[0].port, 3000);
1080 assert_eq!(container.public_endpoints[0].name, "web");
1081 assert!(container.health_check.is_some());
1082 }
1083
1084 #[test]
1085 fn test_public_container_endpoint_options() {
1086 let container = Container::new("router".to_string())
1087 .cluster("compute".to_string())
1088 .code(ContainerCode::Image {
1089 image: "router:latest".to_string(),
1090 })
1091 .cpu(ResourceSpec {
1092 min: "0.25".to_string(),
1093 desired: "0.5".to_string(),
1094 })
1095 .memory(ResourceSpec {
1096 min: "256Mi".to_string(),
1097 desired: "512Mi".to_string(),
1098 })
1099 .public_endpoint(PublicEndpoint {
1100 name: "gateway".to_string(),
1101 port: 8080,
1102 protocol: ExposeProtocol::Http,
1103 host_label: Some("gateway".to_string()),
1104 wildcard_subdomains: true,
1105 })
1106 .permissions("router".to_string())
1107 .build();
1108
1109 assert!(container.validate_public_endpoints().is_ok());
1110 assert_eq!(container.ports.len(), 1);
1111 assert_eq!(container.public_endpoints.len(), 1);
1112 assert_eq!(
1113 container.public_endpoints[0].host_label.as_deref(),
1114 Some("gateway")
1115 );
1116 assert!(container.public_endpoints[0].wildcard_subdomains);
1117 }
1118
1119 #[test]
1120 fn test_public_container_rejects_invalid_host_label() {
1121 let container = Container::new("router".to_string())
1122 .cluster("compute".to_string())
1123 .code(ContainerCode::Image {
1124 image: "router:latest".to_string(),
1125 })
1126 .cpu(ResourceSpec {
1127 min: "0.25".to_string(),
1128 desired: "0.5".to_string(),
1129 })
1130 .memory(ResourceSpec {
1131 min: "256Mi".to_string(),
1132 desired: "512Mi".to_string(),
1133 })
1134 .public_endpoint(PublicEndpoint {
1135 name: "gateway".to_string(),
1136 port: 8080,
1137 protocol: ExposeProtocol::Http,
1138 host_label: Some("bad.label".to_string()),
1139 wildcard_subdomains: true,
1140 })
1141 .permissions("router".to_string())
1142 .build();
1143
1144 assert!(container.validate_public_endpoints().is_err());
1145 }
1146
1147 #[test]
1148 fn test_container_with_links() {
1149 use crate::Storage;
1150
1151 let storage = Storage::new("data".to_string()).build();
1152
1153 let container = Container::new("worker".to_string())
1154 .cluster("compute".to_string())
1155 .code(ContainerCode::Image {
1156 image: "worker:latest".to_string(),
1157 })
1158 .cpu(ResourceSpec {
1159 min: "0.5".to_string(),
1160 desired: "1".to_string(),
1161 })
1162 .memory(ResourceSpec {
1163 min: "512Mi".to_string(),
1164 desired: "1Gi".to_string(),
1165 })
1166 .port(8080)
1167 .replicas(3)
1168 .link(&storage)
1169 .permissions("worker".to_string())
1170 .build();
1171
1172 let deps = container.get_dependencies();
1174 assert_eq!(deps.len(), 2);
1175 }
1176
1177 #[test]
1178 fn test_container_validate_update_immutable_cluster() {
1179 let container1 = Container::new("api".to_string())
1180 .cluster("cluster-1".to_string())
1181 .code(ContainerCode::Image {
1182 image: "myapp:v1".to_string(),
1183 })
1184 .cpu(ResourceSpec {
1185 min: "0.5".to_string(),
1186 desired: "1".to_string(),
1187 })
1188 .memory(ResourceSpec {
1189 min: "512Mi".to_string(),
1190 desired: "1Gi".to_string(),
1191 })
1192 .port(8080)
1193 .replicas(2)
1194 .permissions("execution".to_string())
1195 .build();
1196
1197 let container2 = Container::new("api".to_string())
1198 .cluster("cluster-2".to_string()) .code(ContainerCode::Image {
1200 image: "myapp:v2".to_string(),
1201 })
1202 .cpu(ResourceSpec {
1203 min: "0.5".to_string(),
1204 desired: "1".to_string(),
1205 })
1206 .memory(ResourceSpec {
1207 min: "512Mi".to_string(),
1208 desired: "1Gi".to_string(),
1209 })
1210 .port(8080)
1211 .replicas(2)
1212 .permissions("execution".to_string())
1213 .build();
1214
1215 let result = container1.validate_update(&container2);
1216 assert!(result.is_err());
1217 }
1218
1219 #[test]
1220 fn test_container_validate_update_allowed_changes() {
1221 let container1 = Container::new("api".to_string())
1222 .cluster("compute".to_string())
1223 .code(ContainerCode::Image {
1224 image: "myapp:v1".to_string(),
1225 })
1226 .cpu(ResourceSpec {
1227 min: "0.5".to_string(),
1228 desired: "1".to_string(),
1229 })
1230 .memory(ResourceSpec {
1231 min: "512Mi".to_string(),
1232 desired: "1Gi".to_string(),
1233 })
1234 .port(8080)
1235 .replicas(2)
1236 .permissions("execution".to_string())
1237 .build();
1238
1239 let container2 = Container::new("api".to_string())
1240 .cluster("compute".to_string())
1241 .code(ContainerCode::Image {
1242 image: "myapp:v2".to_string(), })
1244 .cpu(ResourceSpec {
1245 min: "1".to_string(), desired: "2".to_string(),
1247 })
1248 .memory(ResourceSpec {
1249 min: "1Gi".to_string(),
1250 desired: "2Gi".to_string(),
1251 })
1252 .port(8080)
1253 .replicas(5) .permissions("execution".to_string())
1255 .build();
1256
1257 let result = container1.validate_update(&container2);
1258 assert!(result.is_ok());
1259 }
1260
1261 #[test]
1262 fn test_container_serialization() {
1263 let container = Container::new("test".to_string())
1264 .cluster("compute".to_string())
1265 .code(ContainerCode::Image {
1266 image: "test:latest".to_string(),
1267 })
1268 .cpu(ResourceSpec {
1269 min: "0.5".to_string(),
1270 desired: "1".to_string(),
1271 })
1272 .memory(ResourceSpec {
1273 min: "512Mi".to_string(),
1274 desired: "1Gi".to_string(),
1275 })
1276 .port(8080)
1277 .replicas(1)
1278 .permissions("test".to_string())
1279 .build();
1280
1281 let json = serde_json::to_string(&container).unwrap();
1282 let deserialized: Container = serde_json::from_str(&json).unwrap();
1283 assert_eq!(container, deserialized);
1284 }
1285
1286 #[test]
1287 fn test_container_multi_endpoint_validation() {
1288 let container = Container::new("multi-tcp".to_string())
1289 .cluster("compute".to_string())
1290 .code(ContainerCode::Image {
1291 image: "test:latest".to_string(),
1292 })
1293 .cpu(ResourceSpec {
1294 min: "1".to_string(),
1295 desired: "1".to_string(),
1296 })
1297 .memory(ResourceSpec {
1298 min: "1Gi".to_string(),
1299 desired: "1Gi".to_string(),
1300 })
1301 .port(8080)
1302 .public_endpoint(PublicEndpoint {
1303 name: "api".to_string(),
1304 port: 8080,
1305 protocol: ExposeProtocol::Http,
1306 host_label: None,
1307 wildcard_subdomains: false,
1308 })
1309 .public_endpoint(PublicEndpoint {
1310 name: "wildcard".to_string(),
1311 port: 8080,
1312 protocol: ExposeProtocol::Http,
1313 host_label: Some("wildcard".to_string()),
1314 wildcard_subdomains: true,
1315 })
1316 .replicas(1)
1317 .permissions("test".to_string())
1318 .build();
1319
1320 assert!(container.validate_public_endpoints().is_ok());
1321
1322 let invalid_container = Container::new("multi-http".to_string())
1323 .cluster("compute".to_string())
1324 .code(ContainerCode::Image {
1325 image: "test:latest".to_string(),
1326 })
1327 .cpu(ResourceSpec {
1328 min: "1".to_string(),
1329 desired: "1".to_string(),
1330 })
1331 .memory(ResourceSpec {
1332 min: "1Gi".to_string(),
1333 desired: "1Gi".to_string(),
1334 })
1335 .port(8080)
1336 .port(9090)
1337 .public_endpoint(PublicEndpoint {
1338 name: "api".to_string(),
1339 port: 8080,
1340 protocol: ExposeProtocol::Http,
1341 host_label: None,
1342 wildcard_subdomains: false,
1343 })
1344 .public_endpoint(PublicEndpoint {
1345 name: "admin".to_string(),
1346 port: 9090,
1347 protocol: ExposeProtocol::Http,
1348 host_label: None,
1349 wildcard_subdomains: false,
1350 })
1351 .replicas(1)
1352 .permissions("test".to_string())
1353 .build();
1354
1355 assert!(invalid_container.validate_public_endpoints().is_err());
1356 }
1357
1358 #[test]
1359 fn container_rejects_multiple_apex_public_endpoints() {
1360 let container = Container::new("apex-container".to_string())
1361 .cluster("compute".to_string())
1362 .code(ContainerCode::Image {
1363 image: "test:latest".to_string(),
1364 })
1365 .cpu(ResourceSpec {
1366 min: "1".to_string(),
1367 desired: "1".to_string(),
1368 })
1369 .memory(ResourceSpec {
1370 min: "1Gi".to_string(),
1371 desired: "1Gi".to_string(),
1372 })
1373 .port(8080)
1374 .public_endpoint(PublicEndpoint {
1375 name: "web".to_string(),
1376 port: 8080,
1377 protocol: ExposeProtocol::Http,
1378 host_label: Some(APEX_HOST_LABEL.to_string()),
1379 wildcard_subdomains: false,
1380 })
1381 .public_endpoint(PublicEndpoint {
1382 name: "admin".to_string(),
1383 port: 8080,
1384 protocol: ExposeProtocol::Http,
1385 host_label: Some(APEX_HOST_LABEL.to_string()),
1386 wildcard_subdomains: false,
1387 })
1388 .replicas(1)
1389 .permissions("test".to_string())
1390 .build();
1391
1392 assert!(container.validate_public_endpoints().is_err());
1393 }
1394
1395 #[test]
1396 fn test_container_empty_ports_validation() {
1397 let container = Container::new("no-ports".to_string())
1398 .cluster("compute".to_string())
1399 .code(ContainerCode::Image {
1400 image: "test:latest".to_string(),
1401 })
1402 .cpu(ResourceSpec {
1403 min: "1".to_string(),
1404 desired: "1".to_string(),
1405 })
1406 .memory(ResourceSpec {
1407 min: "1Gi".to_string(),
1408 desired: "1Gi".to_string(),
1409 })
1410 .replicas(1)
1411 .permissions("test".to_string())
1412 .build();
1413
1414 assert!(container.validate_public_endpoints().is_ok());
1415 }
1416
1417 #[test]
1418 fn test_container_commands_enabled_defaults_false() {
1419 let container = Container::new("no-commands".to_string())
1420 .cluster("compute".to_string())
1421 .code(ContainerCode::Image {
1422 image: "test:latest".to_string(),
1423 })
1424 .cpu(ResourceSpec {
1425 min: "0.5".to_string(),
1426 desired: "1".to_string(),
1427 })
1428 .memory(ResourceSpec {
1429 min: "512Mi".to_string(),
1430 desired: "1Gi".to_string(),
1431 })
1432 .port(8080)
1433 .permissions("test".to_string())
1434 .build();
1435
1436 assert!(!container.commands_enabled);
1437 }
1438
1439 #[test]
1440 fn test_container_commands_enabled_builder() {
1441 let container = Container::new("cmd-container".to_string())
1442 .cluster("compute".to_string())
1443 .code(ContainerCode::Image {
1444 image: "test:latest".to_string(),
1445 })
1446 .cpu(ResourceSpec {
1447 min: "0.5".to_string(),
1448 desired: "1".to_string(),
1449 })
1450 .memory(ResourceSpec {
1451 min: "512Mi".to_string(),
1452 desired: "1Gi".to_string(),
1453 })
1454 .port(8080)
1455 .permissions("test".to_string())
1456 .commands_enabled(true)
1457 .build();
1458
1459 assert!(container.commands_enabled);
1460 }
1461
1462 #[test]
1463 fn test_container_commands_enabled_serializes_camel_case() {
1464 let container = Container::new("cmd-container".to_string())
1465 .cluster("compute".to_string())
1466 .code(ContainerCode::Image {
1467 image: "test:latest".to_string(),
1468 })
1469 .cpu(ResourceSpec {
1470 min: "0.5".to_string(),
1471 desired: "1".to_string(),
1472 })
1473 .memory(ResourceSpec {
1474 min: "512Mi".to_string(),
1475 desired: "1Gi".to_string(),
1476 })
1477 .port(8080)
1478 .permissions("test".to_string())
1479 .commands_enabled(true)
1480 .build();
1481
1482 let json = serde_json::to_value(&container).expect("container should serialize");
1483 assert_eq!(json["commandsEnabled"], true);
1484
1485 let deserialized: Container =
1486 serde_json::from_value(json).expect("container should deserialize");
1487 assert_eq!(deserialized, container);
1488 }
1489}