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