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
307impl Default for HealthCheck {
308 fn default() -> Self {
310 HealthCheck {
311 path: default_health_path(),
312 port: None,
313 method: default_health_method(),
314 timeout_seconds: default_timeout_seconds(),
315 failure_threshold: default_failure_threshold(),
316 }
317 }
318}
319
320fn default_health_path() -> String {
321 "/health".to_string()
322}
323
324fn default_health_method() -> String {
325 "GET".to_string()
326}
327
328fn default_timeout_seconds() -> u32 {
329 1
330}
331
332fn default_failure_threshold() -> u32 {
333 3
334}
335
336#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
338#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
339#[serde(rename_all = "camelCase")]
340pub struct ContainerPort {
341 pub port: u16,
343}
344
345#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
350#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
351#[serde(rename_all = "camelCase")]
352pub enum ContainerEgress {
353 Internet,
357}
358
359#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Builder)]
398#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
399#[serde(rename_all = "camelCase", deny_unknown_fields)]
400#[builder(start_fn = new)]
401pub struct Container {
402 #[builder(start_fn)]
405 pub id: String,
406
407 #[builder(field)]
409 pub links: Vec<ResourceRef>,
410
411 #[builder(field)]
413 pub ports: Vec<ContainerPort>,
414
415 #[builder(field)]
417 #[serde(default, skip_serializing_if = "Vec::is_empty")]
418 pub kubernetes_secret_mounts: Vec<KubernetesSecretMount>,
419
420 #[builder(field)]
422 #[serde(default, skip_serializing_if = "Vec::is_empty")]
423 pub public_endpoints: Vec<PublicEndpoint>,
424
425 #[serde(default, skip_serializing_if = "Option::is_none")]
427 pub egress: Option<ContainerEgress>,
428
429 #[serde(default, skip_serializing_if = "Option::is_none")]
433 pub tunnel: Option<ContainerTunnel>,
434
435 #[serde(skip_serializing_if = "Option::is_none")]
437 pub kubernetes_liveness_probe: Option<KubernetesHttpProbe>,
438
439 #[serde(skip_serializing_if = "Option::is_none")]
441 pub kubernetes_readiness_probe: Option<KubernetesHttpProbe>,
442
443 #[serde(skip_serializing_if = "Option::is_none")]
445 pub security: Option<ContainerSecurity>,
446
447 #[serde(skip_serializing_if = "Option::is_none")]
450 pub cluster: Option<String>,
451
452 pub code: ContainerCode,
454
455 pub cpu: ResourceSpec,
457
458 pub memory: ResourceSpec,
460
461 #[serde(default, skip_serializing_if = "Option::is_none")]
463 pub resource_choices: Option<ContainerResourceChoices>,
464
465 #[serde(skip_serializing_if = "Option::is_none")]
467 pub gpu: Option<ContainerGpuSpec>,
468
469 #[serde(skip_serializing_if = "Option::is_none")]
471 pub ephemeral_storage: Option<String>,
472
473 #[serde(skip_serializing_if = "Option::is_none")]
475 pub persistent_storage: Option<PersistentStorage>,
476
477 #[serde(skip_serializing_if = "Option::is_none")]
479 pub replicas: Option<u32>,
480
481 #[serde(skip_serializing_if = "Option::is_none")]
483 pub autoscaling: Option<ContainerAutoscaling>,
484
485 #[builder(default = false)]
487 #[serde(default)]
488 pub stateful: bool,
489
490 #[builder(default)]
492 #[serde(default)]
493 pub environment: HashMap<String, String>,
494
495 #[serde(skip_serializing_if = "Option::is_none")]
498 pub pool: Option<String>,
499
500 pub permissions: String,
502
503 #[serde(skip_serializing_if = "Option::is_none")]
505 pub health_check: Option<HealthCheck>,
506
507 #[serde(skip_serializing_if = "Option::is_none")]
509 pub command: Option<Vec<String>>,
510
511 #[builder(default = default_commands_enabled())]
515 #[serde(default = "default_commands_enabled")]
516 #[cfg_attr(feature = "openapi", schema(default = default_commands_enabled))]
517 pub commands_enabled: bool,
518
519 #[serde(skip_serializing_if = "Option::is_none")]
524 #[cfg_attr(feature = "openapi", schema(minimum = 1, maximum = 86400))]
525 pub stop_grace_period_seconds: Option<u32>,
526}
527
528impl Container {
529 pub const RESOURCE_TYPE: ResourceType = ResourceType::from_static("container");
531
532 pub fn id(&self) -> &str {
534 &self.id
535 }
536
537 pub fn get_permissions(&self) -> &str {
539 &self.permissions
540 }
541
542 pub fn is_stateless(&self) -> bool {
544 !self.stateful
545 }
546
547 pub fn validate_network_settings(&self) -> Result<()> {
549 let invalid = |reason: String| {
550 AlienError::new(ErrorData::InvalidResourceUpdate {
551 resource_id: self.id.clone(),
552 reason,
553 })
554 };
555 if self.egress.is_some()
556 && !self
557 .security
558 .as_ref()
559 .is_some_and(|security| security.profile == ContainerSecurityProfile::Restricted)
560 {
561 return Err(invalid(
562 "container egress internet requires security.profile restricted".into(),
563 ));
564 }
565 Ok(())
566 }
567
568 fn validate_public_endpoints(&self) -> Result<()> {
570 let mut endpoint_names = std::collections::HashSet::new();
571 let mut backend_ports = std::collections::HashSet::new();
572 let mut apex_endpoint_name: Option<&str> = None;
573
574 for endpoint in &self.public_endpoints {
575 endpoint.validate_for_resource(&self.id)?;
576
577 if !endpoint_names.insert(endpoint.name.as_str()) {
578 return Err(AlienError::new(ErrorData::InvalidResourceUpdate {
579 resource_id: self.id.clone(),
580 reason: format!("duplicate public endpoint name '{}'", endpoint.name),
581 }));
582 }
583
584 if endpoint.host_label.as_deref() == Some(APEX_HOST_LABEL) {
585 if let Some(existing_name) = apex_endpoint_name {
586 return Err(AlienError::new(ErrorData::InvalidResourceUpdate {
587 resource_id: self.id.clone(),
588 reason: format!(
589 "only one apex public endpoint is allowed per resource; '{}' already uses hostLabel '@'",
590 existing_name
591 ),
592 }));
593 }
594 apex_endpoint_name = Some(endpoint.name.as_str());
595 }
596
597 backend_ports.insert(endpoint.port);
598
599 if !self.ports.iter().any(|port| port.port == endpoint.port) {
600 return Err(AlienError::new(ErrorData::InvalidResourceUpdate {
601 resource_id: self.id.clone(),
602 reason: format!(
603 "public endpoint '{}' references undeclared port {}",
604 endpoint.name, endpoint.port
605 ),
606 }));
607 }
608 }
609
610 if backend_ports.len() > 1 {
611 return Err(AlienError::new(ErrorData::InvalidResourceUpdate {
612 resource_id: self.id.clone(),
613 reason:
614 "public endpoints on one container must currently route to the same backend port"
615 .to_string(),
616 }));
617 }
618
619 Ok(())
620 }
621}
622
623fn default_commands_enabled() -> bool {
624 false
625}
626
627impl<S: container_builder::State> ContainerBuilder<S> {
628 pub fn link<R: ?Sized>(mut self, resource: &R) -> Self
630 where
631 for<'a> &'a R: Into<ResourceRef>,
632 {
633 let resource_ref: ResourceRef = resource.into();
634 self.links.push(resource_ref);
635 self
636 }
637
638 pub fn port(mut self, port: u16) -> Self {
640 self.ports.push(ContainerPort { port });
641 self
642 }
643
644 pub fn kubernetes_secret_mount(mut self, mount: KubernetesSecretMount) -> Self {
646 self.kubernetes_secret_mounts.push(mount);
647 self
648 }
649
650 pub fn public_endpoint(mut self, endpoint: PublicEndpoint) -> Self {
652 if !self.ports.iter().any(|p| p.port == endpoint.port) {
653 self.ports.push(ContainerPort {
654 port: endpoint.port,
655 });
656 }
657 self.public_endpoints.push(endpoint);
658 self
659 }
660}
661
662#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
664#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
665#[serde(rename_all = "camelCase")]
666pub enum ContainerStatus {
667 Pending,
669 Running,
671 Stopped,
673 Failing,
676}
677
678#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
680#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
681#[serde(rename_all = "camelCase")]
682pub struct ReplicaStatus {
683 pub replica_id: String,
685 pub ordinal: Option<u32>,
687 pub machine_id: Option<String>,
689 pub healthy: bool,
691 pub container_ip: Option<String>,
693}
694
695#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
702#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
703#[serde(rename_all = "camelCase", deny_unknown_fields)]
704pub struct ContainerTunnel {
705 pub port: u16,
707}
708
709#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
711#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
712#[serde(rename_all = "camelCase")]
713pub struct ContainerOutputs {
714 pub name: String,
716 pub status: ContainerStatus,
718 pub current_replicas: u32,
720 pub desired_replicas: u32,
722 pub internal_dns: String,
724 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
726 pub public_endpoints: HashMap<String, PublicEndpointOutput>,
727 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
729 pub internal_urls: HashMap<u16, String>,
730 pub replicas: Vec<ReplicaStatus>,
732 #[serde(default, skip_serializing_if = "Vec::is_empty")]
734 pub volumes: Vec<VolumeOutput>,
735 #[serde(default, skip_serializing_if = "Option::is_none")]
737 pub volume_backups: Option<VolumeBackupsStatus>,
738}
739
740#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
742#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
743#[serde(rename_all = "camelCase")]
744pub struct VolumeBackupsStatus {
745 pub state: VolumeBackupsState,
747 #[serde(skip_serializing_if = "Option::is_none")]
749 pub message: Option<String>,
750}
751
752#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
754#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
755#[serde(rename_all = "camelCase")]
756pub enum VolumeBackupsState {
757 Active,
759 Disabled,
761 SetupRequired,
765}
766
767#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
769#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
770#[serde(rename_all = "camelCase")]
771pub struct VolumeOutput {
772 pub ordinal: u32,
774 pub volume_id: String,
776 pub zone: String,
778 #[serde(skip_serializing_if = "Option::is_none")]
780 pub last_snapshot_id: Option<String>,
781 #[serde(skip_serializing_if = "Option::is_none")]
783 pub last_snapshot_at: Option<String>,
784 #[serde(skip_serializing_if = "Option::is_none")]
786 pub last_restore: Option<VolumeRestoreOutput>,
787}
788
789#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
791#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
792#[serde(rename_all = "camelCase")]
793pub struct VolumeRestoreOutput {
794 pub request_id: String,
796 pub snapshot_id: String,
798 pub replaced_volume_snapshot_id: String,
800 pub completed_at: String,
802}
803
804impl ResourceOutputsDefinition for ContainerOutputs {
805 fn get_resource_type(&self) -> ResourceType {
806 Container::RESOURCE_TYPE.clone()
807 }
808
809 fn as_any(&self) -> &dyn Any {
810 self
811 }
812
813 fn box_clone(&self) -> Box<dyn ResourceOutputsDefinition> {
814 Box::new(self.clone())
815 }
816
817 fn outputs_eq(&self, other: &dyn ResourceOutputsDefinition) -> bool {
818 other.as_any().downcast_ref::<ContainerOutputs>() == Some(self)
819 }
820
821 fn to_json_value(&self) -> serde_json::Result<serde_json::Value> {
822 serde_json::to_value(self)
823 }
824}
825
826impl ResourceDefinition for Container {
827 fn get_resource_type(&self) -> ResourceType {
828 Self::RESOURCE_TYPE
829 }
830
831 fn id(&self) -> &str {
832 &self.id
833 }
834
835 fn replace_after_failed_create_is_safe(&self) -> bool {
836 !(self.stateful && self.persistent_storage.is_some())
838 }
839
840 fn get_dependencies(&self) -> Vec<ResourceRef> {
841 let mut deps = self.links.clone();
842 if let Some(key) = self
843 .persistent_storage
844 .as_ref()
845 .and_then(|storage| storage.encryption_key.as_ref())
846 {
847 deps.push(key.clone());
848 }
849 if let Some(cluster) = &self.cluster {
852 deps.push(ResourceRef::new(
853 ComputeCluster::RESOURCE_TYPE.clone(),
854 cluster,
855 ));
856 }
857 deps
858 }
859
860 fn get_permissions(&self) -> Option<&str> {
861 Some(&self.permissions)
862 }
863
864 fn validate_update(&self, new_config: &dyn ResourceDefinition) -> Result<()> {
865 let new_container = new_config
866 .as_any()
867 .downcast_ref::<Container>()
868 .ok_or_else(|| {
869 AlienError::new(ErrorData::UnexpectedResourceType {
870 resource_id: self.id.clone(),
871 expected: Self::RESOURCE_TYPE,
872 actual: new_config.get_resource_type(),
873 })
874 })?;
875
876 new_container.validate_public_endpoints()?;
878 new_container.validate_network_settings()?;
879
880 if self.id != new_container.id {
881 return Err(AlienError::new(ErrorData::InvalidResourceUpdate {
882 resource_id: self.id.clone(),
883 reason: "the 'id' field is immutable".to_string(),
884 }));
885 }
886
887 if self.cluster != new_container.cluster {
889 return Err(AlienError::new(ErrorData::InvalidResourceUpdate {
890 resource_id: self.id.clone(),
891 reason: "the 'cluster' field is immutable".to_string(),
892 }));
893 }
894
895 if self.stateful != new_container.stateful {
897 return Err(AlienError::new(ErrorData::InvalidResourceUpdate {
898 resource_id: self.id.clone(),
899 reason: "the 'stateful' field is immutable".to_string(),
900 }));
901 }
902
903 let old_key = self
904 .persistent_storage
905 .as_ref()
906 .and_then(|storage| storage.encryption_key.as_ref());
907 let new_key = new_container
908 .persistent_storage
909 .as_ref()
910 .and_then(|storage| storage.encryption_key.as_ref());
911 if old_key != new_key {
912 return Err(AlienError::new(ErrorData::InvalidResourceUpdate {
913 resource_id: self.id.clone(),
914 reason: "the 'persistentStorage.encryptionKey' field is immutable once the resource exists".to_string(),
915 }));
916 }
917
918 if self.ports != new_container.ports {
920 return Err(AlienError::new(ErrorData::InvalidResourceUpdate {
921 resource_id: self.id.clone(),
922 reason: "the 'ports' field is immutable".to_string(),
923 }));
924 }
925
926 if self.public_endpoints != new_container.public_endpoints {
927 return Err(AlienError::new(ErrorData::InvalidResourceUpdate {
928 resource_id: self.id.clone(),
929 reason: "the 'publicEndpoints' field is immutable".to_string(),
930 }));
931 }
932
933 if self.pool != new_container.pool {
935 return Err(AlienError::new(ErrorData::InvalidResourceUpdate {
936 resource_id: self.id.clone(),
937 reason: "the 'pool' field is immutable".to_string(),
938 }));
939 }
940
941 Ok(())
942 }
943
944 fn as_any(&self) -> &dyn Any {
945 self
946 }
947
948 fn as_any_mut(&mut self) -> &mut dyn Any {
949 self
950 }
951
952 fn box_clone(&self) -> Box<dyn ResourceDefinition> {
953 Box::new(self.clone())
954 }
955
956 fn resource_eq(&self, other: &dyn ResourceDefinition) -> bool {
957 other.as_any().downcast_ref::<Container>() == Some(self)
958 }
959
960 fn to_json_value(&self) -> serde_json::Result<serde_json::Value> {
961 serde_json::to_value(self)
962 }
963}
964
965#[cfg(test)]
966mod tests {
967 use super::*;
968 use crate::resources::ExposeProtocol;
969
970 fn network_container() -> Container {
971 Container::new("api".to_string())
972 .code(ContainerCode::Image {
973 image: "example:latest".into(),
974 })
975 .cpu(ResourceSpec {
976 min: "1".into(),
977 desired: "1".into(),
978 })
979 .memory(ResourceSpec {
980 min: "512Mi".into(),
981 desired: "512Mi".into(),
982 })
983 .permissions("execution".into())
984 .public_endpoint(PublicEndpoint {
985 name: "web".into(),
986 port: 8080,
987 protocol: ExposeProtocol::Http,
988 host_label: None,
989 wildcard_subdomains: false,
990 })
991 .build()
992 }
993
994 #[test]
995 fn internet_egress_requires_restricted_security_and_is_opt_in() {
996 let mut container = network_container();
997 assert!(serde_json::to_value(&container)
998 .unwrap()
999 .get("egress")
1000 .is_none());
1001 container.egress = Some(ContainerEgress::Internet);
1002 assert!(container.validate_update(&container).is_err());
1003 container.security = Some(ContainerSecurity {
1004 profile: ContainerSecurityProfile::Restricted,
1005 run_as_user: 65532,
1006 run_as_group: 65532,
1007 read_only_root_filesystem: true,
1008 });
1009 container.validate_update(&container).unwrap();
1010 assert_eq!(
1011 serde_json::to_value(&container).unwrap()["egress"],
1012 "internet"
1013 );
1014 }
1015
1016 #[test]
1017 fn test_container_creation_with_autoscaling() {
1018 let container = Container::new("api".to_string())
1019 .cluster("compute".to_string())
1020 .code(ContainerCode::Image {
1021 image: "myapp:latest".to_string(),
1022 })
1023 .cpu(ResourceSpec {
1024 min: "0.5".to_string(),
1025 desired: "1".to_string(),
1026 })
1027 .memory(ResourceSpec {
1028 min: "512Mi".to_string(),
1029 desired: "1Gi".to_string(),
1030 })
1031 .port(8080)
1032 .public_endpoint(PublicEndpoint {
1033 name: "api".to_string(),
1034 port: 8080,
1035 protocol: ExposeProtocol::Http,
1036 host_label: None,
1037 wildcard_subdomains: false,
1038 })
1039 .autoscaling(ContainerAutoscaling {
1040 min: 2,
1041 desired: 3,
1042 max: 10,
1043 target_cpu_percent: Some(70.0),
1044 target_memory_percent: None,
1045 target_http_in_flight_per_replica: Some(100),
1046 max_http_p95_latency_ms: None,
1047 })
1048 .permissions("container-execution".to_string())
1049 .build();
1050
1051 assert_eq!(container.id(), "api");
1052 assert_eq!(container.cluster, Some("compute".to_string()));
1053 assert!(!container.stateful);
1054 assert!(container.autoscaling.is_some());
1055 assert_eq!(container.ports.len(), 1);
1056 assert_eq!(container.ports[0].port, 8080);
1057 }
1058
1059 #[test]
1060 fn container_serializes_stop_grace_period_when_set() {
1061 let container = Container::new("api".to_string())
1062 .cluster("compute".to_string())
1063 .code(ContainerCode::Image {
1064 image: "myapp:latest".to_string(),
1065 })
1066 .cpu(ResourceSpec {
1067 min: "0.5".to_string(),
1068 desired: "1".to_string(),
1069 })
1070 .memory(ResourceSpec {
1071 min: "512Mi".to_string(),
1072 desired: "1Gi".to_string(),
1073 })
1074 .port(8080)
1075 .permissions("container-execution".to_string())
1076 .stop_grace_period_seconds(21_600)
1077 .build();
1078
1079 let json = serde_json::to_value(&container).expect("container should serialize");
1080 assert_eq!(json["stopGracePeriodSeconds"], 21_600);
1081 }
1082
1083 #[test]
1084 fn container_omits_stop_grace_period_when_absent() {
1085 let container = Container::new("api".to_string())
1086 .cluster("compute".to_string())
1087 .code(ContainerCode::Image {
1088 image: "myapp:latest".to_string(),
1089 })
1090 .cpu(ResourceSpec {
1091 min: "0.5".to_string(),
1092 desired: "1".to_string(),
1093 })
1094 .memory(ResourceSpec {
1095 min: "512Mi".to_string(),
1096 desired: "1Gi".to_string(),
1097 })
1098 .port(8080)
1099 .permissions("container-execution".to_string())
1100 .build();
1101
1102 let json = serde_json::to_value(&container).expect("container should serialize");
1103 assert!(json.get("stopGracePeriodSeconds").is_none());
1104 }
1105
1106 #[test]
1107 fn test_stateful_container_with_storage() {
1108 let container = Container::new("postgres".to_string())
1109 .cluster("compute".to_string())
1110 .code(ContainerCode::Image {
1111 image: "postgres:16".to_string(),
1112 })
1113 .cpu(ResourceSpec {
1114 min: "1".to_string(),
1115 desired: "2".to_string(),
1116 })
1117 .memory(ResourceSpec {
1118 min: "2Gi".to_string(),
1119 desired: "4Gi".to_string(),
1120 })
1121 .port(5432)
1122 .stateful(true)
1123 .replicas(1)
1124 .persistent_storage(PersistentStorage {
1125 encryption_key: None,
1126 size: "100Gi".to_string(),
1127 mount_path: "/var/lib/postgresql/data".to_string(),
1128 backups: VolumeBackups::default(),
1129 })
1130 .permissions("database".to_string())
1131 .build();
1132
1133 assert_eq!(container.id(), "postgres");
1134 assert!(container.stateful);
1135 assert!(container.replicas.is_some());
1136 assert!(container.persistent_storage.is_some());
1137 }
1138
1139 #[test]
1140 fn test_public_container() {
1141 let container = Container::new("frontend".to_string())
1142 .cluster("compute".to_string())
1143 .code(ContainerCode::Image {
1144 image: "frontend:latest".to_string(),
1145 })
1146 .cpu(ResourceSpec {
1147 min: "0.25".to_string(),
1148 desired: "0.5".to_string(),
1149 })
1150 .memory(ResourceSpec {
1151 min: "256Mi".to_string(),
1152 desired: "512Mi".to_string(),
1153 })
1154 .port(3000)
1155 .public_endpoint(PublicEndpoint {
1156 name: "web".to_string(),
1157 port: 3000,
1158 protocol: ExposeProtocol::Http,
1159 host_label: None,
1160 wildcard_subdomains: false,
1161 })
1162 .autoscaling(ContainerAutoscaling {
1163 min: 2,
1164 desired: 2,
1165 max: 20,
1166 target_cpu_percent: None,
1167 target_memory_percent: None,
1168 target_http_in_flight_per_replica: Some(50),
1169 max_http_p95_latency_ms: Some(100.0),
1170 })
1171 .health_check(HealthCheck {
1172 path: "/health".to_string(),
1173 port: None,
1174 method: "GET".to_string(),
1175 timeout_seconds: 1,
1176 failure_threshold: 3,
1177 })
1178 .permissions("frontend".to_string())
1179 .build();
1180
1181 assert_eq!(container.ports[0].port, 3000);
1182 assert_eq!(container.public_endpoints[0].name, "web");
1183 assert!(container.health_check.is_some());
1184 }
1185
1186 #[test]
1187 fn test_public_container_endpoint_options() {
1188 let container = Container::new("router".to_string())
1189 .cluster("compute".to_string())
1190 .code(ContainerCode::Image {
1191 image: "router:latest".to_string(),
1192 })
1193 .cpu(ResourceSpec {
1194 min: "0.25".to_string(),
1195 desired: "0.5".to_string(),
1196 })
1197 .memory(ResourceSpec {
1198 min: "256Mi".to_string(),
1199 desired: "512Mi".to_string(),
1200 })
1201 .public_endpoint(PublicEndpoint {
1202 name: "gateway".to_string(),
1203 port: 8080,
1204 protocol: ExposeProtocol::Http,
1205 host_label: Some("gateway".to_string()),
1206 wildcard_subdomains: true,
1207 })
1208 .permissions("router".to_string())
1209 .build();
1210
1211 assert!(container.validate_public_endpoints().is_ok());
1212 assert_eq!(container.ports.len(), 1);
1213 assert_eq!(container.public_endpoints.len(), 1);
1214 assert_eq!(
1215 container.public_endpoints[0].host_label.as_deref(),
1216 Some("gateway")
1217 );
1218 assert!(container.public_endpoints[0].wildcard_subdomains);
1219 }
1220
1221 #[test]
1222 fn test_public_container_rejects_invalid_host_label() {
1223 let container = Container::new("router".to_string())
1224 .cluster("compute".to_string())
1225 .code(ContainerCode::Image {
1226 image: "router:latest".to_string(),
1227 })
1228 .cpu(ResourceSpec {
1229 min: "0.25".to_string(),
1230 desired: "0.5".to_string(),
1231 })
1232 .memory(ResourceSpec {
1233 min: "256Mi".to_string(),
1234 desired: "512Mi".to_string(),
1235 })
1236 .public_endpoint(PublicEndpoint {
1237 name: "gateway".to_string(),
1238 port: 8080,
1239 protocol: ExposeProtocol::Http,
1240 host_label: Some("bad.label".to_string()),
1241 wildcard_subdomains: true,
1242 })
1243 .permissions("router".to_string())
1244 .build();
1245
1246 assert!(container.validate_public_endpoints().is_err());
1247 }
1248
1249 #[test]
1250 fn test_container_with_links() {
1251 use crate::Storage;
1252
1253 let storage = Storage::new("data".to_string()).build();
1254
1255 let container = Container::new("worker".to_string())
1256 .cluster("compute".to_string())
1257 .code(ContainerCode::Image {
1258 image: "worker:latest".to_string(),
1259 })
1260 .cpu(ResourceSpec {
1261 min: "0.5".to_string(),
1262 desired: "1".to_string(),
1263 })
1264 .memory(ResourceSpec {
1265 min: "512Mi".to_string(),
1266 desired: "1Gi".to_string(),
1267 })
1268 .port(8080)
1269 .replicas(3)
1270 .link(&storage)
1271 .permissions("worker".to_string())
1272 .build();
1273
1274 let deps = container.get_dependencies();
1276 assert_eq!(deps.len(), 2);
1277 }
1278
1279 #[test]
1280 fn test_container_validate_update_immutable_cluster() {
1281 let container1 = Container::new("api".to_string())
1282 .cluster("cluster-1".to_string())
1283 .code(ContainerCode::Image {
1284 image: "myapp:v1".to_string(),
1285 })
1286 .cpu(ResourceSpec {
1287 min: "0.5".to_string(),
1288 desired: "1".to_string(),
1289 })
1290 .memory(ResourceSpec {
1291 min: "512Mi".to_string(),
1292 desired: "1Gi".to_string(),
1293 })
1294 .port(8080)
1295 .replicas(2)
1296 .permissions("execution".to_string())
1297 .build();
1298
1299 let container2 = Container::new("api".to_string())
1300 .cluster("cluster-2".to_string()) .code(ContainerCode::Image {
1302 image: "myapp:v2".to_string(),
1303 })
1304 .cpu(ResourceSpec {
1305 min: "0.5".to_string(),
1306 desired: "1".to_string(),
1307 })
1308 .memory(ResourceSpec {
1309 min: "512Mi".to_string(),
1310 desired: "1Gi".to_string(),
1311 })
1312 .port(8080)
1313 .replicas(2)
1314 .permissions("execution".to_string())
1315 .build();
1316
1317 let result = container1.validate_update(&container2);
1318 assert!(result.is_err());
1319 }
1320
1321 #[test]
1322 fn test_container_validate_update_allowed_changes() {
1323 let container1 = Container::new("api".to_string())
1324 .cluster("compute".to_string())
1325 .code(ContainerCode::Image {
1326 image: "myapp:v1".to_string(),
1327 })
1328 .cpu(ResourceSpec {
1329 min: "0.5".to_string(),
1330 desired: "1".to_string(),
1331 })
1332 .memory(ResourceSpec {
1333 min: "512Mi".to_string(),
1334 desired: "1Gi".to_string(),
1335 })
1336 .port(8080)
1337 .replicas(2)
1338 .permissions("execution".to_string())
1339 .build();
1340
1341 let container2 = Container::new("api".to_string())
1342 .cluster("compute".to_string())
1343 .code(ContainerCode::Image {
1344 image: "myapp:v2".to_string(), })
1346 .cpu(ResourceSpec {
1347 min: "1".to_string(), desired: "2".to_string(),
1349 })
1350 .memory(ResourceSpec {
1351 min: "1Gi".to_string(),
1352 desired: "2Gi".to_string(),
1353 })
1354 .port(8080)
1355 .replicas(5) .permissions("execution".to_string())
1357 .build();
1358
1359 let result = container1.validate_update(&container2);
1360 assert!(result.is_ok());
1361 }
1362
1363 #[test]
1364 fn test_container_serialization() {
1365 let container = Container::new("test".to_string())
1366 .cluster("compute".to_string())
1367 .code(ContainerCode::Image {
1368 image: "test:latest".to_string(),
1369 })
1370 .cpu(ResourceSpec {
1371 min: "0.5".to_string(),
1372 desired: "1".to_string(),
1373 })
1374 .memory(ResourceSpec {
1375 min: "512Mi".to_string(),
1376 desired: "1Gi".to_string(),
1377 })
1378 .port(8080)
1379 .replicas(1)
1380 .permissions("test".to_string())
1381 .build();
1382
1383 let json = serde_json::to_string(&container).unwrap();
1384 let deserialized: Container = serde_json::from_str(&json).unwrap();
1385 assert_eq!(container, deserialized);
1386 }
1387
1388 #[test]
1389 fn test_container_multi_endpoint_validation() {
1390 let container = Container::new("multi-tcp".to_string())
1391 .cluster("compute".to_string())
1392 .code(ContainerCode::Image {
1393 image: "test:latest".to_string(),
1394 })
1395 .cpu(ResourceSpec {
1396 min: "1".to_string(),
1397 desired: "1".to_string(),
1398 })
1399 .memory(ResourceSpec {
1400 min: "1Gi".to_string(),
1401 desired: "1Gi".to_string(),
1402 })
1403 .port(8080)
1404 .public_endpoint(PublicEndpoint {
1405 name: "api".to_string(),
1406 port: 8080,
1407 protocol: ExposeProtocol::Http,
1408 host_label: None,
1409 wildcard_subdomains: false,
1410 })
1411 .public_endpoint(PublicEndpoint {
1412 name: "wildcard".to_string(),
1413 port: 8080,
1414 protocol: ExposeProtocol::Http,
1415 host_label: Some("wildcard".to_string()),
1416 wildcard_subdomains: true,
1417 })
1418 .replicas(1)
1419 .permissions("test".to_string())
1420 .build();
1421
1422 assert!(container.validate_public_endpoints().is_ok());
1423
1424 let invalid_container = Container::new("multi-http".to_string())
1425 .cluster("compute".to_string())
1426 .code(ContainerCode::Image {
1427 image: "test:latest".to_string(),
1428 })
1429 .cpu(ResourceSpec {
1430 min: "1".to_string(),
1431 desired: "1".to_string(),
1432 })
1433 .memory(ResourceSpec {
1434 min: "1Gi".to_string(),
1435 desired: "1Gi".to_string(),
1436 })
1437 .port(8080)
1438 .port(9090)
1439 .public_endpoint(PublicEndpoint {
1440 name: "api".to_string(),
1441 port: 8080,
1442 protocol: ExposeProtocol::Http,
1443 host_label: None,
1444 wildcard_subdomains: false,
1445 })
1446 .public_endpoint(PublicEndpoint {
1447 name: "admin".to_string(),
1448 port: 9090,
1449 protocol: ExposeProtocol::Http,
1450 host_label: None,
1451 wildcard_subdomains: false,
1452 })
1453 .replicas(1)
1454 .permissions("test".to_string())
1455 .build();
1456
1457 assert!(invalid_container.validate_public_endpoints().is_err());
1458 }
1459
1460 #[test]
1461 fn container_rejects_multiple_apex_public_endpoints() {
1462 let container = Container::new("apex-container".to_string())
1463 .cluster("compute".to_string())
1464 .code(ContainerCode::Image {
1465 image: "test:latest".to_string(),
1466 })
1467 .cpu(ResourceSpec {
1468 min: "1".to_string(),
1469 desired: "1".to_string(),
1470 })
1471 .memory(ResourceSpec {
1472 min: "1Gi".to_string(),
1473 desired: "1Gi".to_string(),
1474 })
1475 .port(8080)
1476 .public_endpoint(PublicEndpoint {
1477 name: "web".to_string(),
1478 port: 8080,
1479 protocol: ExposeProtocol::Http,
1480 host_label: Some(APEX_HOST_LABEL.to_string()),
1481 wildcard_subdomains: false,
1482 })
1483 .public_endpoint(PublicEndpoint {
1484 name: "admin".to_string(),
1485 port: 8080,
1486 protocol: ExposeProtocol::Http,
1487 host_label: Some(APEX_HOST_LABEL.to_string()),
1488 wildcard_subdomains: false,
1489 })
1490 .replicas(1)
1491 .permissions("test".to_string())
1492 .build();
1493
1494 assert!(container.validate_public_endpoints().is_err());
1495 }
1496
1497 #[test]
1498 fn test_container_empty_ports_validation() {
1499 let container = Container::new("no-ports".to_string())
1500 .cluster("compute".to_string())
1501 .code(ContainerCode::Image {
1502 image: "test:latest".to_string(),
1503 })
1504 .cpu(ResourceSpec {
1505 min: "1".to_string(),
1506 desired: "1".to_string(),
1507 })
1508 .memory(ResourceSpec {
1509 min: "1Gi".to_string(),
1510 desired: "1Gi".to_string(),
1511 })
1512 .replicas(1)
1513 .permissions("test".to_string())
1514 .build();
1515
1516 assert!(container.validate_public_endpoints().is_ok());
1517 }
1518
1519 #[test]
1520 fn test_container_commands_enabled_defaults_false() {
1521 let container = Container::new("no-commands".to_string())
1522 .cluster("compute".to_string())
1523 .code(ContainerCode::Image {
1524 image: "test:latest".to_string(),
1525 })
1526 .cpu(ResourceSpec {
1527 min: "0.5".to_string(),
1528 desired: "1".to_string(),
1529 })
1530 .memory(ResourceSpec {
1531 min: "512Mi".to_string(),
1532 desired: "1Gi".to_string(),
1533 })
1534 .port(8080)
1535 .permissions("test".to_string())
1536 .build();
1537
1538 assert!(!container.commands_enabled);
1539 }
1540
1541 #[test]
1542 fn test_container_commands_enabled_builder() {
1543 let container = Container::new("cmd-container".to_string())
1544 .cluster("compute".to_string())
1545 .code(ContainerCode::Image {
1546 image: "test:latest".to_string(),
1547 })
1548 .cpu(ResourceSpec {
1549 min: "0.5".to_string(),
1550 desired: "1".to_string(),
1551 })
1552 .memory(ResourceSpec {
1553 min: "512Mi".to_string(),
1554 desired: "1Gi".to_string(),
1555 })
1556 .port(8080)
1557 .permissions("test".to_string())
1558 .commands_enabled(true)
1559 .build();
1560
1561 assert!(container.commands_enabled);
1562 }
1563
1564 #[test]
1565 fn test_container_commands_enabled_serializes_camel_case() {
1566 let container = Container::new("cmd-container".to_string())
1567 .cluster("compute".to_string())
1568 .code(ContainerCode::Image {
1569 image: "test:latest".to_string(),
1570 })
1571 .cpu(ResourceSpec {
1572 min: "0.5".to_string(),
1573 desired: "1".to_string(),
1574 })
1575 .memory(ResourceSpec {
1576 min: "512Mi".to_string(),
1577 desired: "1Gi".to_string(),
1578 })
1579 .port(8080)
1580 .permissions("test".to_string())
1581 .commands_enabled(true)
1582 .build();
1583
1584 let json = serde_json::to_value(&container).expect("container should serialize");
1585 assert_eq!(json["commandsEnabled"], true);
1586
1587 let deserialized: Container =
1588 serde_json::from_value(json).expect("container should deserialize");
1589 assert_eq!(deserialized, container);
1590 }
1591}