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