1use crate::error::{ErrorData, Result};
15use crate::resource::{ResourceDefinition, ResourceOutputsDefinition, ResourceRef, ResourceType};
16use crate::resources::{
17 ComputeCluster, PublicEndpoint, PublicEndpointOutput, ToolchainConfig, APEX_HOST_LABEL,
18};
19use alien_error::AlienError;
20use bon::Builder;
21use serde::{Deserialize, Serialize};
22use std::any::Any;
23use std::collections::HashMap;
24use std::fmt::Debug;
25
26#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
28#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
29#[serde(rename_all = "camelCase", tag = "type")]
30pub enum ContainerCode {
31 #[serde(rename_all = "camelCase")]
33 Image {
34 image: String,
36 },
37 #[serde(rename_all = "camelCase")]
39 Source {
40 src: String,
42 toolchain: ToolchainConfig,
44 },
45}
46
47#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
49#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
50#[serde(rename_all = "camelCase")]
51pub struct ResourceSpec {
52 pub min: String,
54 pub desired: String,
56}
57
58#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
60#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
61#[serde(rename_all = "camelCase")]
62pub struct ContainerGpuSpec {
63 #[serde(rename = "type")]
65 pub gpu_type: String,
66 pub count: u32,
68}
69
70#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
72#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
73#[serde(rename_all = "camelCase")]
74pub struct PersistentStorage {
75 pub size: String,
77 pub mount_path: String,
79}
80
81#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
84#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
85#[serde(rename_all = "camelCase")]
86pub struct KubernetesSecretMount {
87 pub secret_name: String,
89 pub mount_path: String,
91}
92
93#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
95#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
96#[serde(rename_all = "camelCase")]
97pub struct KubernetesHttpProbe {
98 pub path: String,
100 pub port: u16,
102}
103
104#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
106#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
107#[serde(rename_all = "camelCase")]
108pub enum ContainerSecurityProfile {
109 Restricted,
110}
111
112#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
114#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
115#[serde(rename_all = "camelCase")]
116pub struct ContainerSecurity {
117 pub profile: ContainerSecurityProfile,
119 pub run_as_user: i64,
121 pub run_as_group: i64,
123 pub read_only_root_filesystem: bool,
125}
126
127#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
129#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
130#[serde(rename_all = "camelCase")]
131pub struct ContainerAutoscaling {
132 pub min: u32,
134 pub desired: u32,
136 pub max: u32,
138 #[serde(skip_serializing_if = "Option::is_none")]
140 pub target_cpu_percent: Option<f64>,
141 #[serde(skip_serializing_if = "Option::is_none")]
143 pub target_memory_percent: Option<f64>,
144 #[serde(skip_serializing_if = "Option::is_none")]
146 pub target_http_in_flight_per_replica: Option<u32>,
147 #[serde(skip_serializing_if = "Option::is_none")]
149 pub max_http_p95_latency_ms: Option<f64>,
150}
151
152#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
154#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
155#[serde(rename_all = "camelCase")]
156pub struct HealthCheck {
157 #[serde(default = "default_health_path")]
159 pub path: String,
160 #[serde(skip_serializing_if = "Option::is_none")]
162 pub port: Option<u16>,
163 #[serde(default = "default_health_method")]
165 pub method: String,
166 #[serde(default = "default_timeout_seconds")]
168 pub timeout_seconds: u32,
169 #[serde(default = "default_failure_threshold")]
171 pub failure_threshold: u32,
172}
173
174fn default_health_path() -> String {
175 "/health".to_string()
176}
177
178fn default_health_method() -> String {
179 "GET".to_string()
180}
181
182fn default_timeout_seconds() -> u32 {
183 1
184}
185
186fn default_failure_threshold() -> u32 {
187 3
188}
189
190#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
192#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
193#[serde(rename_all = "camelCase")]
194pub struct ContainerPort {
195 pub port: u16,
197}
198
199#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Builder)]
238#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
239#[serde(rename_all = "camelCase", deny_unknown_fields)]
240#[builder(start_fn = new)]
241pub struct Container {
242 #[builder(start_fn)]
245 pub id: String,
246
247 #[builder(field)]
249 pub links: Vec<ResourceRef>,
250
251 #[builder(field)]
253 pub ports: Vec<ContainerPort>,
254
255 #[builder(field)]
257 #[serde(default, skip_serializing_if = "Vec::is_empty")]
258 pub kubernetes_secret_mounts: Vec<KubernetesSecretMount>,
259
260 #[builder(field)]
262 #[serde(default, skip_serializing_if = "Vec::is_empty")]
263 pub public_endpoints: Vec<PublicEndpoint>,
264
265 #[serde(default, skip_serializing_if = "Option::is_none")]
269 pub tunnel: Option<ContainerTunnel>,
270
271 #[serde(skip_serializing_if = "Option::is_none")]
273 pub kubernetes_liveness_probe: Option<KubernetesHttpProbe>,
274
275 #[serde(skip_serializing_if = "Option::is_none")]
277 pub kubernetes_readiness_probe: Option<KubernetesHttpProbe>,
278
279 #[serde(skip_serializing_if = "Option::is_none")]
281 pub security: Option<ContainerSecurity>,
282
283 #[serde(skip_serializing_if = "Option::is_none")]
286 pub cluster: Option<String>,
287
288 pub code: ContainerCode,
290
291 pub cpu: ResourceSpec,
293
294 pub memory: ResourceSpec,
296
297 #[serde(skip_serializing_if = "Option::is_none")]
299 pub gpu: Option<ContainerGpuSpec>,
300
301 #[serde(skip_serializing_if = "Option::is_none")]
303 pub ephemeral_storage: Option<String>,
304
305 #[serde(skip_serializing_if = "Option::is_none")]
307 pub persistent_storage: Option<PersistentStorage>,
308
309 #[serde(skip_serializing_if = "Option::is_none")]
311 pub replicas: Option<u32>,
312
313 #[serde(skip_serializing_if = "Option::is_none")]
315 pub autoscaling: Option<ContainerAutoscaling>,
316
317 #[builder(default = false)]
319 #[serde(default)]
320 pub stateful: bool,
321
322 #[builder(default)]
324 #[serde(default)]
325 pub environment: HashMap<String, String>,
326
327 #[serde(skip_serializing_if = "Option::is_none")]
330 pub pool: Option<String>,
331
332 pub permissions: String,
334
335 #[serde(skip_serializing_if = "Option::is_none")]
337 pub health_check: Option<HealthCheck>,
338
339 #[serde(skip_serializing_if = "Option::is_none")]
341 pub command: Option<Vec<String>>,
342
343 #[builder(default = default_commands_enabled())]
347 #[serde(default = "default_commands_enabled")]
348 #[cfg_attr(feature = "openapi", schema(default = default_commands_enabled))]
349 pub commands_enabled: bool,
350
351 #[serde(skip_serializing_if = "Option::is_none")]
356 #[cfg_attr(feature = "openapi", schema(minimum = 1, maximum = 86400))]
357 pub stop_grace_period_seconds: Option<u32>,
358}
359
360impl Container {
361 pub const RESOURCE_TYPE: ResourceType = ResourceType::from_static("container");
363
364 pub fn id(&self) -> &str {
366 &self.id
367 }
368
369 pub fn get_permissions(&self) -> &str {
371 &self.permissions
372 }
373
374 pub fn is_stateless(&self) -> bool {
376 !self.stateful
377 }
378
379 fn validate_public_endpoints(&self) -> Result<()> {
381 let mut endpoint_names = std::collections::HashSet::new();
382 let mut backend_ports = std::collections::HashSet::new();
383 let mut apex_endpoint_name: Option<&str> = None;
384
385 for endpoint in &self.public_endpoints {
386 endpoint.validate_for_resource(&self.id)?;
387
388 if !endpoint_names.insert(endpoint.name.as_str()) {
389 return Err(AlienError::new(ErrorData::InvalidResourceUpdate {
390 resource_id: self.id.clone(),
391 reason: format!("duplicate public endpoint name '{}'", endpoint.name),
392 }));
393 }
394
395 if endpoint.host_label.as_deref() == Some(APEX_HOST_LABEL) {
396 if let Some(existing_name) = apex_endpoint_name {
397 return Err(AlienError::new(ErrorData::InvalidResourceUpdate {
398 resource_id: self.id.clone(),
399 reason: format!(
400 "only one apex public endpoint is allowed per resource; '{}' already uses hostLabel '@'",
401 existing_name
402 ),
403 }));
404 }
405 apex_endpoint_name = Some(endpoint.name.as_str());
406 }
407
408 backend_ports.insert(endpoint.port);
409
410 if !self.ports.iter().any(|port| port.port == endpoint.port) {
411 return Err(AlienError::new(ErrorData::InvalidResourceUpdate {
412 resource_id: self.id.clone(),
413 reason: format!(
414 "public endpoint '{}' references undeclared port {}",
415 endpoint.name, endpoint.port
416 ),
417 }));
418 }
419 }
420
421 if backend_ports.len() > 1 {
422 return Err(AlienError::new(ErrorData::InvalidResourceUpdate {
423 resource_id: self.id.clone(),
424 reason:
425 "public endpoints on one container must currently route to the same backend port"
426 .to_string(),
427 }));
428 }
429
430 Ok(())
431 }
432}
433
434fn default_commands_enabled() -> bool {
435 false
436}
437
438impl<S: container_builder::State> ContainerBuilder<S> {
439 pub fn link<R: ?Sized>(mut self, resource: &R) -> Self
441 where
442 for<'a> &'a R: Into<ResourceRef>,
443 {
444 let resource_ref: ResourceRef = resource.into();
445 self.links.push(resource_ref);
446 self
447 }
448
449 pub fn port(mut self, port: u16) -> Self {
451 self.ports.push(ContainerPort { port });
452 self
453 }
454
455 pub fn kubernetes_secret_mount(mut self, mount: KubernetesSecretMount) -> Self {
457 self.kubernetes_secret_mounts.push(mount);
458 self
459 }
460
461 pub fn public_endpoint(mut self, endpoint: PublicEndpoint) -> Self {
463 if !self.ports.iter().any(|p| p.port == endpoint.port) {
464 self.ports.push(ContainerPort {
465 port: endpoint.port,
466 });
467 }
468 self.public_endpoints.push(endpoint);
469 self
470 }
471}
472
473#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
475#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
476#[serde(rename_all = "camelCase")]
477pub enum ContainerStatus {
478 Pending,
480 Running,
482 Stopped,
484 Failing,
487}
488
489#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
491#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
492#[serde(rename_all = "camelCase")]
493pub struct ReplicaStatus {
494 pub replica_id: String,
496 pub ordinal: Option<u32>,
498 pub machine_id: Option<String>,
500 pub healthy: bool,
502 pub container_ip: Option<String>,
504}
505
506#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
513#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
514#[serde(rename_all = "camelCase", deny_unknown_fields)]
515pub struct ContainerTunnel {
516 pub port: u16,
518}
519
520#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
522#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
523#[serde(rename_all = "camelCase")]
524pub struct ContainerOutputs {
525 pub name: String,
527 pub status: ContainerStatus,
529 pub current_replicas: u32,
531 pub desired_replicas: u32,
533 pub internal_dns: String,
535 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
537 pub public_endpoints: HashMap<String, PublicEndpointOutput>,
538 pub replicas: Vec<ReplicaStatus>,
540}
541
542impl ResourceOutputsDefinition for ContainerOutputs {
543 fn get_resource_type(&self) -> ResourceType {
544 Container::RESOURCE_TYPE.clone()
545 }
546
547 fn as_any(&self) -> &dyn Any {
548 self
549 }
550
551 fn box_clone(&self) -> Box<dyn ResourceOutputsDefinition> {
552 Box::new(self.clone())
553 }
554
555 fn outputs_eq(&self, other: &dyn ResourceOutputsDefinition) -> bool {
556 other.as_any().downcast_ref::<ContainerOutputs>() == Some(self)
557 }
558
559 fn to_json_value(&self) -> serde_json::Result<serde_json::Value> {
560 serde_json::to_value(self)
561 }
562}
563
564impl ResourceDefinition for Container {
565 fn get_resource_type(&self) -> ResourceType {
566 Self::RESOURCE_TYPE
567 }
568
569 fn id(&self) -> &str {
570 &self.id
571 }
572
573 fn get_dependencies(&self) -> Vec<ResourceRef> {
574 let mut deps = self.links.clone();
575 if let Some(cluster) = &self.cluster {
578 deps.push(ResourceRef::new(
579 ComputeCluster::RESOURCE_TYPE.clone(),
580 cluster,
581 ));
582 }
583 deps
584 }
585
586 fn get_permissions(&self) -> Option<&str> {
587 Some(&self.permissions)
588 }
589
590 fn validate_update(&self, new_config: &dyn ResourceDefinition) -> Result<()> {
591 let new_container = new_config
592 .as_any()
593 .downcast_ref::<Container>()
594 .ok_or_else(|| {
595 AlienError::new(ErrorData::UnexpectedResourceType {
596 resource_id: self.id.clone(),
597 expected: Self::RESOURCE_TYPE,
598 actual: new_config.get_resource_type(),
599 })
600 })?;
601
602 new_container.validate_public_endpoints()?;
604
605 if self.id != new_container.id {
606 return Err(AlienError::new(ErrorData::InvalidResourceUpdate {
607 resource_id: self.id.clone(),
608 reason: "the 'id' field is immutable".to_string(),
609 }));
610 }
611
612 if self.cluster != new_container.cluster {
614 return Err(AlienError::new(ErrorData::InvalidResourceUpdate {
615 resource_id: self.id.clone(),
616 reason: "the 'cluster' field is immutable".to_string(),
617 }));
618 }
619
620 if self.stateful != new_container.stateful {
622 return Err(AlienError::new(ErrorData::InvalidResourceUpdate {
623 resource_id: self.id.clone(),
624 reason: "the 'stateful' field is immutable".to_string(),
625 }));
626 }
627
628 if self.ports != new_container.ports {
630 return Err(AlienError::new(ErrorData::InvalidResourceUpdate {
631 resource_id: self.id.clone(),
632 reason: "the 'ports' field is immutable".to_string(),
633 }));
634 }
635
636 if self.public_endpoints != new_container.public_endpoints {
637 return Err(AlienError::new(ErrorData::InvalidResourceUpdate {
638 resource_id: self.id.clone(),
639 reason: "the 'publicEndpoints' field is immutable".to_string(),
640 }));
641 }
642
643 if self.pool != new_container.pool {
645 return Err(AlienError::new(ErrorData::InvalidResourceUpdate {
646 resource_id: self.id.clone(),
647 reason: "the 'pool' field is immutable".to_string(),
648 }));
649 }
650
651 Ok(())
652 }
653
654 fn as_any(&self) -> &dyn Any {
655 self
656 }
657
658 fn as_any_mut(&mut self) -> &mut dyn Any {
659 self
660 }
661
662 fn box_clone(&self) -> Box<dyn ResourceDefinition> {
663 Box::new(self.clone())
664 }
665
666 fn resource_eq(&self, other: &dyn ResourceDefinition) -> bool {
667 other.as_any().downcast_ref::<Container>() == Some(self)
668 }
669
670 fn to_json_value(&self) -> serde_json::Result<serde_json::Value> {
671 serde_json::to_value(self)
672 }
673}
674
675#[cfg(test)]
676mod tests {
677 use super::*;
678 use crate::resources::ExposeProtocol;
679
680 #[test]
681 fn test_container_creation_with_autoscaling() {
682 let container = Container::new("api".to_string())
683 .cluster("compute".to_string())
684 .code(ContainerCode::Image {
685 image: "myapp:latest".to_string(),
686 })
687 .cpu(ResourceSpec {
688 min: "0.5".to_string(),
689 desired: "1".to_string(),
690 })
691 .memory(ResourceSpec {
692 min: "512Mi".to_string(),
693 desired: "1Gi".to_string(),
694 })
695 .port(8080)
696 .public_endpoint(PublicEndpoint {
697 name: "api".to_string(),
698 port: 8080,
699 protocol: ExposeProtocol::Http,
700 host_label: None,
701 wildcard_subdomains: false,
702 })
703 .autoscaling(ContainerAutoscaling {
704 min: 2,
705 desired: 3,
706 max: 10,
707 target_cpu_percent: Some(70.0),
708 target_memory_percent: None,
709 target_http_in_flight_per_replica: Some(100),
710 max_http_p95_latency_ms: None,
711 })
712 .permissions("container-execution".to_string())
713 .build();
714
715 assert_eq!(container.id(), "api");
716 assert_eq!(container.cluster, Some("compute".to_string()));
717 assert!(!container.stateful);
718 assert!(container.autoscaling.is_some());
719 assert_eq!(container.ports.len(), 1);
720 assert_eq!(container.ports[0].port, 8080);
721 }
722
723 #[test]
724 fn container_serializes_stop_grace_period_when_set() {
725 let container = Container::new("api".to_string())
726 .cluster("compute".to_string())
727 .code(ContainerCode::Image {
728 image: "myapp:latest".to_string(),
729 })
730 .cpu(ResourceSpec {
731 min: "0.5".to_string(),
732 desired: "1".to_string(),
733 })
734 .memory(ResourceSpec {
735 min: "512Mi".to_string(),
736 desired: "1Gi".to_string(),
737 })
738 .port(8080)
739 .permissions("container-execution".to_string())
740 .stop_grace_period_seconds(21_600)
741 .build();
742
743 let json = serde_json::to_value(&container).expect("container should serialize");
744 assert_eq!(json["stopGracePeriodSeconds"], 21_600);
745 }
746
747 #[test]
748 fn container_omits_stop_grace_period_when_absent() {
749 let container = Container::new("api".to_string())
750 .cluster("compute".to_string())
751 .code(ContainerCode::Image {
752 image: "myapp:latest".to_string(),
753 })
754 .cpu(ResourceSpec {
755 min: "0.5".to_string(),
756 desired: "1".to_string(),
757 })
758 .memory(ResourceSpec {
759 min: "512Mi".to_string(),
760 desired: "1Gi".to_string(),
761 })
762 .port(8080)
763 .permissions("container-execution".to_string())
764 .build();
765
766 let json = serde_json::to_value(&container).expect("container should serialize");
767 assert!(json.get("stopGracePeriodSeconds").is_none());
768 }
769
770 #[test]
771 fn test_stateful_container_with_storage() {
772 let container = Container::new("postgres".to_string())
773 .cluster("compute".to_string())
774 .code(ContainerCode::Image {
775 image: "postgres:16".to_string(),
776 })
777 .cpu(ResourceSpec {
778 min: "1".to_string(),
779 desired: "2".to_string(),
780 })
781 .memory(ResourceSpec {
782 min: "2Gi".to_string(),
783 desired: "4Gi".to_string(),
784 })
785 .port(5432)
786 .stateful(true)
787 .replicas(1)
788 .persistent_storage(PersistentStorage {
789 size: "100Gi".to_string(),
790 mount_path: "/var/lib/postgresql/data".to_string(),
791 })
792 .permissions("database".to_string())
793 .build();
794
795 assert_eq!(container.id(), "postgres");
796 assert!(container.stateful);
797 assert!(container.replicas.is_some());
798 assert!(container.persistent_storage.is_some());
799 }
800
801 #[test]
802 fn test_public_container() {
803 let container = Container::new("frontend".to_string())
804 .cluster("compute".to_string())
805 .code(ContainerCode::Image {
806 image: "frontend:latest".to_string(),
807 })
808 .cpu(ResourceSpec {
809 min: "0.25".to_string(),
810 desired: "0.5".to_string(),
811 })
812 .memory(ResourceSpec {
813 min: "256Mi".to_string(),
814 desired: "512Mi".to_string(),
815 })
816 .port(3000)
817 .public_endpoint(PublicEndpoint {
818 name: "web".to_string(),
819 port: 3000,
820 protocol: ExposeProtocol::Http,
821 host_label: None,
822 wildcard_subdomains: false,
823 })
824 .autoscaling(ContainerAutoscaling {
825 min: 2,
826 desired: 2,
827 max: 20,
828 target_cpu_percent: None,
829 target_memory_percent: None,
830 target_http_in_flight_per_replica: Some(50),
831 max_http_p95_latency_ms: Some(100.0),
832 })
833 .health_check(HealthCheck {
834 path: "/health".to_string(),
835 port: None,
836 method: "GET".to_string(),
837 timeout_seconds: 1,
838 failure_threshold: 3,
839 })
840 .permissions("frontend".to_string())
841 .build();
842
843 assert_eq!(container.ports[0].port, 3000);
844 assert_eq!(container.public_endpoints[0].name, "web");
845 assert!(container.health_check.is_some());
846 }
847
848 #[test]
849 fn test_public_container_endpoint_options() {
850 let container = Container::new("router".to_string())
851 .cluster("compute".to_string())
852 .code(ContainerCode::Image {
853 image: "router:latest".to_string(),
854 })
855 .cpu(ResourceSpec {
856 min: "0.25".to_string(),
857 desired: "0.5".to_string(),
858 })
859 .memory(ResourceSpec {
860 min: "256Mi".to_string(),
861 desired: "512Mi".to_string(),
862 })
863 .public_endpoint(PublicEndpoint {
864 name: "gateway".to_string(),
865 port: 8080,
866 protocol: ExposeProtocol::Http,
867 host_label: Some("gateway".to_string()),
868 wildcard_subdomains: true,
869 })
870 .permissions("router".to_string())
871 .build();
872
873 assert!(container.validate_public_endpoints().is_ok());
874 assert_eq!(container.ports.len(), 1);
875 assert_eq!(container.public_endpoints.len(), 1);
876 assert_eq!(
877 container.public_endpoints[0].host_label.as_deref(),
878 Some("gateway")
879 );
880 assert!(container.public_endpoints[0].wildcard_subdomains);
881 }
882
883 #[test]
884 fn test_public_container_rejects_invalid_host_label() {
885 let container = Container::new("router".to_string())
886 .cluster("compute".to_string())
887 .code(ContainerCode::Image {
888 image: "router:latest".to_string(),
889 })
890 .cpu(ResourceSpec {
891 min: "0.25".to_string(),
892 desired: "0.5".to_string(),
893 })
894 .memory(ResourceSpec {
895 min: "256Mi".to_string(),
896 desired: "512Mi".to_string(),
897 })
898 .public_endpoint(PublicEndpoint {
899 name: "gateway".to_string(),
900 port: 8080,
901 protocol: ExposeProtocol::Http,
902 host_label: Some("bad.label".to_string()),
903 wildcard_subdomains: true,
904 })
905 .permissions("router".to_string())
906 .build();
907
908 assert!(container.validate_public_endpoints().is_err());
909 }
910
911 #[test]
912 fn test_container_with_links() {
913 use crate::Storage;
914
915 let storage = Storage::new("data".to_string()).build();
916
917 let container = Container::new("worker".to_string())
918 .cluster("compute".to_string())
919 .code(ContainerCode::Image {
920 image: "worker:latest".to_string(),
921 })
922 .cpu(ResourceSpec {
923 min: "0.5".to_string(),
924 desired: "1".to_string(),
925 })
926 .memory(ResourceSpec {
927 min: "512Mi".to_string(),
928 desired: "1Gi".to_string(),
929 })
930 .port(8080)
931 .replicas(3)
932 .link(&storage)
933 .permissions("worker".to_string())
934 .build();
935
936 let deps = container.get_dependencies();
938 assert_eq!(deps.len(), 2);
939 }
940
941 #[test]
942 fn test_container_validate_update_immutable_cluster() {
943 let container1 = Container::new("api".to_string())
944 .cluster("cluster-1".to_string())
945 .code(ContainerCode::Image {
946 image: "myapp:v1".to_string(),
947 })
948 .cpu(ResourceSpec {
949 min: "0.5".to_string(),
950 desired: "1".to_string(),
951 })
952 .memory(ResourceSpec {
953 min: "512Mi".to_string(),
954 desired: "1Gi".to_string(),
955 })
956 .port(8080)
957 .replicas(2)
958 .permissions("execution".to_string())
959 .build();
960
961 let container2 = Container::new("api".to_string())
962 .cluster("cluster-2".to_string()) .code(ContainerCode::Image {
964 image: "myapp:v2".to_string(),
965 })
966 .cpu(ResourceSpec {
967 min: "0.5".to_string(),
968 desired: "1".to_string(),
969 })
970 .memory(ResourceSpec {
971 min: "512Mi".to_string(),
972 desired: "1Gi".to_string(),
973 })
974 .port(8080)
975 .replicas(2)
976 .permissions("execution".to_string())
977 .build();
978
979 let result = container1.validate_update(&container2);
980 assert!(result.is_err());
981 }
982
983 #[test]
984 fn test_container_validate_update_allowed_changes() {
985 let container1 = Container::new("api".to_string())
986 .cluster("compute".to_string())
987 .code(ContainerCode::Image {
988 image: "myapp:v1".to_string(),
989 })
990 .cpu(ResourceSpec {
991 min: "0.5".to_string(),
992 desired: "1".to_string(),
993 })
994 .memory(ResourceSpec {
995 min: "512Mi".to_string(),
996 desired: "1Gi".to_string(),
997 })
998 .port(8080)
999 .replicas(2)
1000 .permissions("execution".to_string())
1001 .build();
1002
1003 let container2 = Container::new("api".to_string())
1004 .cluster("compute".to_string())
1005 .code(ContainerCode::Image {
1006 image: "myapp:v2".to_string(), })
1008 .cpu(ResourceSpec {
1009 min: "1".to_string(), desired: "2".to_string(),
1011 })
1012 .memory(ResourceSpec {
1013 min: "1Gi".to_string(),
1014 desired: "2Gi".to_string(),
1015 })
1016 .port(8080)
1017 .replicas(5) .permissions("execution".to_string())
1019 .build();
1020
1021 let result = container1.validate_update(&container2);
1022 assert!(result.is_ok());
1023 }
1024
1025 #[test]
1026 fn test_container_serialization() {
1027 let container = Container::new("test".to_string())
1028 .cluster("compute".to_string())
1029 .code(ContainerCode::Image {
1030 image: "test:latest".to_string(),
1031 })
1032 .cpu(ResourceSpec {
1033 min: "0.5".to_string(),
1034 desired: "1".to_string(),
1035 })
1036 .memory(ResourceSpec {
1037 min: "512Mi".to_string(),
1038 desired: "1Gi".to_string(),
1039 })
1040 .port(8080)
1041 .replicas(1)
1042 .permissions("test".to_string())
1043 .build();
1044
1045 let json = serde_json::to_string(&container).unwrap();
1046 let deserialized: Container = serde_json::from_str(&json).unwrap();
1047 assert_eq!(container, deserialized);
1048 }
1049
1050 #[test]
1051 fn test_container_multi_endpoint_validation() {
1052 let container = Container::new("multi-tcp".to_string())
1053 .cluster("compute".to_string())
1054 .code(ContainerCode::Image {
1055 image: "test:latest".to_string(),
1056 })
1057 .cpu(ResourceSpec {
1058 min: "1".to_string(),
1059 desired: "1".to_string(),
1060 })
1061 .memory(ResourceSpec {
1062 min: "1Gi".to_string(),
1063 desired: "1Gi".to_string(),
1064 })
1065 .port(8080)
1066 .public_endpoint(PublicEndpoint {
1067 name: "api".to_string(),
1068 port: 8080,
1069 protocol: ExposeProtocol::Http,
1070 host_label: None,
1071 wildcard_subdomains: false,
1072 })
1073 .public_endpoint(PublicEndpoint {
1074 name: "wildcard".to_string(),
1075 port: 8080,
1076 protocol: ExposeProtocol::Http,
1077 host_label: Some("wildcard".to_string()),
1078 wildcard_subdomains: true,
1079 })
1080 .replicas(1)
1081 .permissions("test".to_string())
1082 .build();
1083
1084 assert!(container.validate_public_endpoints().is_ok());
1085
1086 let invalid_container = Container::new("multi-http".to_string())
1087 .cluster("compute".to_string())
1088 .code(ContainerCode::Image {
1089 image: "test:latest".to_string(),
1090 })
1091 .cpu(ResourceSpec {
1092 min: "1".to_string(),
1093 desired: "1".to_string(),
1094 })
1095 .memory(ResourceSpec {
1096 min: "1Gi".to_string(),
1097 desired: "1Gi".to_string(),
1098 })
1099 .port(8080)
1100 .port(9090)
1101 .public_endpoint(PublicEndpoint {
1102 name: "api".to_string(),
1103 port: 8080,
1104 protocol: ExposeProtocol::Http,
1105 host_label: None,
1106 wildcard_subdomains: false,
1107 })
1108 .public_endpoint(PublicEndpoint {
1109 name: "admin".to_string(),
1110 port: 9090,
1111 protocol: ExposeProtocol::Http,
1112 host_label: None,
1113 wildcard_subdomains: false,
1114 })
1115 .replicas(1)
1116 .permissions("test".to_string())
1117 .build();
1118
1119 assert!(invalid_container.validate_public_endpoints().is_err());
1120 }
1121
1122 #[test]
1123 fn container_rejects_multiple_apex_public_endpoints() {
1124 let container = Container::new("apex-container".to_string())
1125 .cluster("compute".to_string())
1126 .code(ContainerCode::Image {
1127 image: "test:latest".to_string(),
1128 })
1129 .cpu(ResourceSpec {
1130 min: "1".to_string(),
1131 desired: "1".to_string(),
1132 })
1133 .memory(ResourceSpec {
1134 min: "1Gi".to_string(),
1135 desired: "1Gi".to_string(),
1136 })
1137 .port(8080)
1138 .public_endpoint(PublicEndpoint {
1139 name: "web".to_string(),
1140 port: 8080,
1141 protocol: ExposeProtocol::Http,
1142 host_label: Some(APEX_HOST_LABEL.to_string()),
1143 wildcard_subdomains: false,
1144 })
1145 .public_endpoint(PublicEndpoint {
1146 name: "admin".to_string(),
1147 port: 8080,
1148 protocol: ExposeProtocol::Http,
1149 host_label: Some(APEX_HOST_LABEL.to_string()),
1150 wildcard_subdomains: false,
1151 })
1152 .replicas(1)
1153 .permissions("test".to_string())
1154 .build();
1155
1156 assert!(container.validate_public_endpoints().is_err());
1157 }
1158
1159 #[test]
1160 fn test_container_empty_ports_validation() {
1161 let container = Container::new("no-ports".to_string())
1162 .cluster("compute".to_string())
1163 .code(ContainerCode::Image {
1164 image: "test:latest".to_string(),
1165 })
1166 .cpu(ResourceSpec {
1167 min: "1".to_string(),
1168 desired: "1".to_string(),
1169 })
1170 .memory(ResourceSpec {
1171 min: "1Gi".to_string(),
1172 desired: "1Gi".to_string(),
1173 })
1174 .replicas(1)
1175 .permissions("test".to_string())
1176 .build();
1177
1178 assert!(container.validate_public_endpoints().is_ok());
1179 }
1180
1181 #[test]
1182 fn test_container_commands_enabled_defaults_false() {
1183 let container = Container::new("no-commands".to_string())
1184 .cluster("compute".to_string())
1185 .code(ContainerCode::Image {
1186 image: "test:latest".to_string(),
1187 })
1188 .cpu(ResourceSpec {
1189 min: "0.5".to_string(),
1190 desired: "1".to_string(),
1191 })
1192 .memory(ResourceSpec {
1193 min: "512Mi".to_string(),
1194 desired: "1Gi".to_string(),
1195 })
1196 .port(8080)
1197 .permissions("test".to_string())
1198 .build();
1199
1200 assert!(!container.commands_enabled);
1201 }
1202
1203 #[test]
1204 fn test_container_commands_enabled_builder() {
1205 let container = Container::new("cmd-container".to_string())
1206 .cluster("compute".to_string())
1207 .code(ContainerCode::Image {
1208 image: "test:latest".to_string(),
1209 })
1210 .cpu(ResourceSpec {
1211 min: "0.5".to_string(),
1212 desired: "1".to_string(),
1213 })
1214 .memory(ResourceSpec {
1215 min: "512Mi".to_string(),
1216 desired: "1Gi".to_string(),
1217 })
1218 .port(8080)
1219 .permissions("test".to_string())
1220 .commands_enabled(true)
1221 .build();
1222
1223 assert!(container.commands_enabled);
1224 }
1225
1226 #[test]
1227 fn test_container_commands_enabled_serializes_camel_case() {
1228 let container = Container::new("cmd-container".to_string())
1229 .cluster("compute".to_string())
1230 .code(ContainerCode::Image {
1231 image: "test:latest".to_string(),
1232 })
1233 .cpu(ResourceSpec {
1234 min: "0.5".to_string(),
1235 desired: "1".to_string(),
1236 })
1237 .memory(ResourceSpec {
1238 min: "512Mi".to_string(),
1239 desired: "1Gi".to_string(),
1240 })
1241 .port(8080)
1242 .permissions("test".to_string())
1243 .commands_enabled(true)
1244 .build();
1245
1246 let json = serde_json::to_value(&container).expect("container should serialize");
1247 assert_eq!(json["commandsEnabled"], true);
1248
1249 let deserialized: Container =
1250 serde_json::from_value(json).expect("container should deserialize");
1251 assert_eq!(deserialized, container);
1252 }
1253}