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, Serialize, Deserialize)]
83#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
84#[serde(rename_all = "camelCase")]
85pub struct ContainerAutoscaling {
86 pub min: u32,
88 pub desired: u32,
90 pub max: u32,
92 #[serde(skip_serializing_if = "Option::is_none")]
94 pub target_cpu_percent: Option<f64>,
95 #[serde(skip_serializing_if = "Option::is_none")]
97 pub target_memory_percent: Option<f64>,
98 #[serde(skip_serializing_if = "Option::is_none")]
100 pub target_http_in_flight_per_replica: Option<u32>,
101 #[serde(skip_serializing_if = "Option::is_none")]
103 pub max_http_p95_latency_ms: Option<f64>,
104}
105
106#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
108#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
109#[serde(rename_all = "camelCase")]
110pub struct HealthCheck {
111 #[serde(default = "default_health_path")]
113 pub path: String,
114 #[serde(skip_serializing_if = "Option::is_none")]
116 pub port: Option<u16>,
117 #[serde(default = "default_health_method")]
119 pub method: String,
120 #[serde(default = "default_timeout_seconds")]
122 pub timeout_seconds: u32,
123 #[serde(default = "default_failure_threshold")]
125 pub failure_threshold: u32,
126}
127
128fn default_health_path() -> String {
129 "/health".to_string()
130}
131
132fn default_health_method() -> String {
133 "GET".to_string()
134}
135
136fn default_timeout_seconds() -> u32 {
137 1
138}
139
140fn default_failure_threshold() -> u32 {
141 3
142}
143
144#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
146#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
147#[serde(rename_all = "camelCase")]
148pub struct ContainerPort {
149 pub port: u16,
151}
152
153#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Builder)]
192#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
193#[serde(rename_all = "camelCase", deny_unknown_fields)]
194#[builder(start_fn = new)]
195pub struct Container {
196 #[builder(start_fn)]
199 pub id: String,
200
201 #[builder(field)]
203 pub links: Vec<ResourceRef>,
204
205 #[builder(field)]
207 pub ports: Vec<ContainerPort>,
208
209 #[builder(field)]
211 #[serde(default, skip_serializing_if = "Vec::is_empty")]
212 pub public_endpoints: Vec<PublicEndpoint>,
213
214 #[serde(skip_serializing_if = "Option::is_none")]
217 pub cluster: Option<String>,
218
219 pub code: ContainerCode,
221
222 pub cpu: ResourceSpec,
224
225 pub memory: ResourceSpec,
227
228 #[serde(skip_serializing_if = "Option::is_none")]
230 pub gpu: Option<ContainerGpuSpec>,
231
232 #[serde(skip_serializing_if = "Option::is_none")]
234 pub ephemeral_storage: Option<String>,
235
236 #[serde(skip_serializing_if = "Option::is_none")]
238 pub persistent_storage: Option<PersistentStorage>,
239
240 #[serde(skip_serializing_if = "Option::is_none")]
242 pub replicas: Option<u32>,
243
244 #[serde(skip_serializing_if = "Option::is_none")]
246 pub autoscaling: Option<ContainerAutoscaling>,
247
248 #[builder(default = false)]
250 #[serde(default)]
251 pub stateful: bool,
252
253 #[builder(default)]
255 #[serde(default)]
256 pub environment: HashMap<String, String>,
257
258 #[serde(skip_serializing_if = "Option::is_none")]
261 pub pool: Option<String>,
262
263 pub permissions: String,
265
266 #[serde(skip_serializing_if = "Option::is_none")]
268 pub health_check: Option<HealthCheck>,
269
270 #[serde(skip_serializing_if = "Option::is_none")]
272 pub command: Option<Vec<String>>,
273
274 #[serde(skip_serializing_if = "Option::is_none")]
279 #[cfg_attr(feature = "openapi", schema(minimum = 1, maximum = 86400))]
280 pub stop_grace_period_seconds: Option<u32>,
281}
282
283impl Container {
284 pub const RESOURCE_TYPE: ResourceType = ResourceType::from_static("container");
286
287 pub fn id(&self) -> &str {
289 &self.id
290 }
291
292 pub fn get_permissions(&self) -> &str {
294 &self.permissions
295 }
296
297 pub fn is_stateless(&self) -> bool {
299 !self.stateful
300 }
301
302 fn validate_public_endpoints(&self) -> Result<()> {
304 let mut endpoint_names = std::collections::HashSet::new();
305 let mut backend_ports = std::collections::HashSet::new();
306 let mut apex_endpoint_name: Option<&str> = None;
307
308 for endpoint in &self.public_endpoints {
309 endpoint.validate_for_resource(&self.id)?;
310
311 if !endpoint_names.insert(endpoint.name.as_str()) {
312 return Err(AlienError::new(ErrorData::InvalidResourceUpdate {
313 resource_id: self.id.clone(),
314 reason: format!("duplicate public endpoint name '{}'", endpoint.name),
315 }));
316 }
317
318 if endpoint.host_label.as_deref() == Some(APEX_HOST_LABEL) {
319 if let Some(existing_name) = apex_endpoint_name {
320 return Err(AlienError::new(ErrorData::InvalidResourceUpdate {
321 resource_id: self.id.clone(),
322 reason: format!(
323 "only one apex public endpoint is allowed per resource; '{}' already uses hostLabel '@'",
324 existing_name
325 ),
326 }));
327 }
328 apex_endpoint_name = Some(endpoint.name.as_str());
329 }
330
331 backend_ports.insert(endpoint.port);
332
333 if !self.ports.iter().any(|port| port.port == endpoint.port) {
334 return Err(AlienError::new(ErrorData::InvalidResourceUpdate {
335 resource_id: self.id.clone(),
336 reason: format!(
337 "public endpoint '{}' references undeclared port {}",
338 endpoint.name, endpoint.port
339 ),
340 }));
341 }
342 }
343
344 if backend_ports.len() > 1 {
345 return Err(AlienError::new(ErrorData::InvalidResourceUpdate {
346 resource_id: self.id.clone(),
347 reason:
348 "public endpoints on one container must currently route to the same backend port"
349 .to_string(),
350 }));
351 }
352
353 Ok(())
354 }
355}
356
357impl<S: container_builder::State> ContainerBuilder<S> {
358 pub fn link<R: ?Sized>(mut self, resource: &R) -> Self
360 where
361 for<'a> &'a R: Into<ResourceRef>,
362 {
363 let resource_ref: ResourceRef = resource.into();
364 self.links.push(resource_ref);
365 self
366 }
367
368 pub fn port(mut self, port: u16) -> Self {
370 self.ports.push(ContainerPort { port });
371 self
372 }
373
374 pub fn public_endpoint(mut self, endpoint: PublicEndpoint) -> Self {
376 if !self.ports.iter().any(|p| p.port == endpoint.port) {
377 self.ports.push(ContainerPort {
378 port: endpoint.port,
379 });
380 }
381 self.public_endpoints.push(endpoint);
382 self
383 }
384}
385
386#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
388#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
389#[serde(rename_all = "camelCase")]
390pub enum ContainerStatus {
391 Pending,
393 Running,
395 Stopped,
397 Failing,
400}
401
402#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
404#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
405#[serde(rename_all = "camelCase")]
406pub struct ReplicaStatus {
407 pub replica_id: String,
409 pub ordinal: Option<u32>,
411 pub machine_id: Option<String>,
413 pub healthy: bool,
415 pub container_ip: Option<String>,
417}
418
419#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
421#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
422#[serde(rename_all = "camelCase")]
423pub struct ContainerOutputs {
424 pub name: String,
426 pub status: ContainerStatus,
428 pub current_replicas: u32,
430 pub desired_replicas: u32,
432 pub internal_dns: String,
434 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
436 pub public_endpoints: HashMap<String, PublicEndpointOutput>,
437 pub replicas: Vec<ReplicaStatus>,
439}
440
441impl ResourceOutputsDefinition for ContainerOutputs {
442 fn get_resource_type(&self) -> ResourceType {
443 Container::RESOURCE_TYPE.clone()
444 }
445
446 fn as_any(&self) -> &dyn Any {
447 self
448 }
449
450 fn box_clone(&self) -> Box<dyn ResourceOutputsDefinition> {
451 Box::new(self.clone())
452 }
453
454 fn outputs_eq(&self, other: &dyn ResourceOutputsDefinition) -> bool {
455 other.as_any().downcast_ref::<ContainerOutputs>() == Some(self)
456 }
457
458 fn to_json_value(&self) -> serde_json::Result<serde_json::Value> {
459 serde_json::to_value(self)
460 }
461}
462
463impl ResourceDefinition for Container {
464 fn get_resource_type(&self) -> ResourceType {
465 Self::RESOURCE_TYPE
466 }
467
468 fn id(&self) -> &str {
469 &self.id
470 }
471
472 fn get_dependencies(&self) -> Vec<ResourceRef> {
473 let mut deps = self.links.clone();
474 if let Some(cluster) = &self.cluster {
477 deps.push(ResourceRef::new(
478 ComputeCluster::RESOURCE_TYPE.clone(),
479 cluster,
480 ));
481 }
482 deps
483 }
484
485 fn get_permissions(&self) -> Option<&str> {
486 Some(&self.permissions)
487 }
488
489 fn validate_update(&self, new_config: &dyn ResourceDefinition) -> Result<()> {
490 let new_container = new_config
491 .as_any()
492 .downcast_ref::<Container>()
493 .ok_or_else(|| {
494 AlienError::new(ErrorData::UnexpectedResourceType {
495 resource_id: self.id.clone(),
496 expected: Self::RESOURCE_TYPE,
497 actual: new_config.get_resource_type(),
498 })
499 })?;
500
501 new_container.validate_public_endpoints()?;
503
504 if self.id != new_container.id {
505 return Err(AlienError::new(ErrorData::InvalidResourceUpdate {
506 resource_id: self.id.clone(),
507 reason: "the 'id' field is immutable".to_string(),
508 }));
509 }
510
511 if self.cluster != new_container.cluster {
513 return Err(AlienError::new(ErrorData::InvalidResourceUpdate {
514 resource_id: self.id.clone(),
515 reason: "the 'cluster' field is immutable".to_string(),
516 }));
517 }
518
519 if self.stateful != new_container.stateful {
521 return Err(AlienError::new(ErrorData::InvalidResourceUpdate {
522 resource_id: self.id.clone(),
523 reason: "the 'stateful' field is immutable".to_string(),
524 }));
525 }
526
527 if self.ports != new_container.ports {
529 return Err(AlienError::new(ErrorData::InvalidResourceUpdate {
530 resource_id: self.id.clone(),
531 reason: "the 'ports' field is immutable".to_string(),
532 }));
533 }
534
535 if self.public_endpoints != new_container.public_endpoints {
536 return Err(AlienError::new(ErrorData::InvalidResourceUpdate {
537 resource_id: self.id.clone(),
538 reason: "the 'publicEndpoints' field is immutable".to_string(),
539 }));
540 }
541
542 if self.pool != new_container.pool {
544 return Err(AlienError::new(ErrorData::InvalidResourceUpdate {
545 resource_id: self.id.clone(),
546 reason: "the 'pool' field is immutable".to_string(),
547 }));
548 }
549
550 Ok(())
551 }
552
553 fn as_any(&self) -> &dyn Any {
554 self
555 }
556
557 fn as_any_mut(&mut self) -> &mut dyn Any {
558 self
559 }
560
561 fn box_clone(&self) -> Box<dyn ResourceDefinition> {
562 Box::new(self.clone())
563 }
564
565 fn resource_eq(&self, other: &dyn ResourceDefinition) -> bool {
566 other.as_any().downcast_ref::<Container>() == Some(self)
567 }
568
569 fn to_json_value(&self) -> serde_json::Result<serde_json::Value> {
570 serde_json::to_value(self)
571 }
572}
573
574#[cfg(test)]
575mod tests {
576 use super::*;
577 use crate::resources::ExposeProtocol;
578
579 #[test]
580 fn test_container_creation_with_autoscaling() {
581 let container = Container::new("api".to_string())
582 .cluster("compute".to_string())
583 .code(ContainerCode::Image {
584 image: "myapp:latest".to_string(),
585 })
586 .cpu(ResourceSpec {
587 min: "0.5".to_string(),
588 desired: "1".to_string(),
589 })
590 .memory(ResourceSpec {
591 min: "512Mi".to_string(),
592 desired: "1Gi".to_string(),
593 })
594 .port(8080)
595 .public_endpoint(PublicEndpoint {
596 name: "api".to_string(),
597 port: 8080,
598 protocol: ExposeProtocol::Http,
599 host_label: None,
600 wildcard_subdomains: false,
601 })
602 .autoscaling(ContainerAutoscaling {
603 min: 2,
604 desired: 3,
605 max: 10,
606 target_cpu_percent: Some(70.0),
607 target_memory_percent: None,
608 target_http_in_flight_per_replica: Some(100),
609 max_http_p95_latency_ms: None,
610 })
611 .permissions("container-execution".to_string())
612 .build();
613
614 assert_eq!(container.id(), "api");
615 assert_eq!(container.cluster, Some("compute".to_string()));
616 assert!(!container.stateful);
617 assert!(container.autoscaling.is_some());
618 assert_eq!(container.ports.len(), 1);
619 assert_eq!(container.ports[0].port, 8080);
620 }
621
622 #[test]
623 fn container_serializes_stop_grace_period_when_set() {
624 let container = Container::new("api".to_string())
625 .cluster("compute".to_string())
626 .code(ContainerCode::Image {
627 image: "myapp:latest".to_string(),
628 })
629 .cpu(ResourceSpec {
630 min: "0.5".to_string(),
631 desired: "1".to_string(),
632 })
633 .memory(ResourceSpec {
634 min: "512Mi".to_string(),
635 desired: "1Gi".to_string(),
636 })
637 .port(8080)
638 .permissions("container-execution".to_string())
639 .stop_grace_period_seconds(21_600)
640 .build();
641
642 let json = serde_json::to_value(&container).expect("container should serialize");
643 assert_eq!(json["stopGracePeriodSeconds"], 21_600);
644 }
645
646 #[test]
647 fn container_omits_stop_grace_period_when_absent() {
648 let container = Container::new("api".to_string())
649 .cluster("compute".to_string())
650 .code(ContainerCode::Image {
651 image: "myapp:latest".to_string(),
652 })
653 .cpu(ResourceSpec {
654 min: "0.5".to_string(),
655 desired: "1".to_string(),
656 })
657 .memory(ResourceSpec {
658 min: "512Mi".to_string(),
659 desired: "1Gi".to_string(),
660 })
661 .port(8080)
662 .permissions("container-execution".to_string())
663 .build();
664
665 let json = serde_json::to_value(&container).expect("container should serialize");
666 assert!(json.get("stopGracePeriodSeconds").is_none());
667 }
668
669 #[test]
670 fn test_stateful_container_with_storage() {
671 let container = Container::new("postgres".to_string())
672 .cluster("compute".to_string())
673 .code(ContainerCode::Image {
674 image: "postgres:16".to_string(),
675 })
676 .cpu(ResourceSpec {
677 min: "1".to_string(),
678 desired: "2".to_string(),
679 })
680 .memory(ResourceSpec {
681 min: "2Gi".to_string(),
682 desired: "4Gi".to_string(),
683 })
684 .port(5432)
685 .stateful(true)
686 .replicas(1)
687 .persistent_storage(PersistentStorage {
688 size: "100Gi".to_string(),
689 mount_path: "/var/lib/postgresql/data".to_string(),
690 })
691 .permissions("database".to_string())
692 .build();
693
694 assert_eq!(container.id(), "postgres");
695 assert!(container.stateful);
696 assert!(container.replicas.is_some());
697 assert!(container.persistent_storage.is_some());
698 }
699
700 #[test]
701 fn test_public_container() {
702 let container = Container::new("frontend".to_string())
703 .cluster("compute".to_string())
704 .code(ContainerCode::Image {
705 image: "frontend:latest".to_string(),
706 })
707 .cpu(ResourceSpec {
708 min: "0.25".to_string(),
709 desired: "0.5".to_string(),
710 })
711 .memory(ResourceSpec {
712 min: "256Mi".to_string(),
713 desired: "512Mi".to_string(),
714 })
715 .port(3000)
716 .public_endpoint(PublicEndpoint {
717 name: "web".to_string(),
718 port: 3000,
719 protocol: ExposeProtocol::Http,
720 host_label: None,
721 wildcard_subdomains: false,
722 })
723 .autoscaling(ContainerAutoscaling {
724 min: 2,
725 desired: 2,
726 max: 20,
727 target_cpu_percent: None,
728 target_memory_percent: None,
729 target_http_in_flight_per_replica: Some(50),
730 max_http_p95_latency_ms: Some(100.0),
731 })
732 .health_check(HealthCheck {
733 path: "/health".to_string(),
734 port: None,
735 method: "GET".to_string(),
736 timeout_seconds: 1,
737 failure_threshold: 3,
738 })
739 .permissions("frontend".to_string())
740 .build();
741
742 assert_eq!(container.ports[0].port, 3000);
743 assert_eq!(container.public_endpoints[0].name, "web");
744 assert!(container.health_check.is_some());
745 }
746
747 #[test]
748 fn test_public_container_endpoint_options() {
749 let container = Container::new("router".to_string())
750 .cluster("compute".to_string())
751 .code(ContainerCode::Image {
752 image: "router:latest".to_string(),
753 })
754 .cpu(ResourceSpec {
755 min: "0.25".to_string(),
756 desired: "0.5".to_string(),
757 })
758 .memory(ResourceSpec {
759 min: "256Mi".to_string(),
760 desired: "512Mi".to_string(),
761 })
762 .public_endpoint(PublicEndpoint {
763 name: "gateway".to_string(),
764 port: 8080,
765 protocol: ExposeProtocol::Http,
766 host_label: Some("gateway".to_string()),
767 wildcard_subdomains: true,
768 })
769 .permissions("router".to_string())
770 .build();
771
772 assert!(container.validate_public_endpoints().is_ok());
773 assert_eq!(container.ports.len(), 1);
774 assert_eq!(container.public_endpoints.len(), 1);
775 assert_eq!(
776 container.public_endpoints[0].host_label.as_deref(),
777 Some("gateway")
778 );
779 assert!(container.public_endpoints[0].wildcard_subdomains);
780 }
781
782 #[test]
783 fn test_public_container_rejects_invalid_host_label() {
784 let container = Container::new("router".to_string())
785 .cluster("compute".to_string())
786 .code(ContainerCode::Image {
787 image: "router:latest".to_string(),
788 })
789 .cpu(ResourceSpec {
790 min: "0.25".to_string(),
791 desired: "0.5".to_string(),
792 })
793 .memory(ResourceSpec {
794 min: "256Mi".to_string(),
795 desired: "512Mi".to_string(),
796 })
797 .public_endpoint(PublicEndpoint {
798 name: "gateway".to_string(),
799 port: 8080,
800 protocol: ExposeProtocol::Http,
801 host_label: Some("bad.label".to_string()),
802 wildcard_subdomains: true,
803 })
804 .permissions("router".to_string())
805 .build();
806
807 assert!(container.validate_public_endpoints().is_err());
808 }
809
810 #[test]
811 fn test_container_with_links() {
812 use crate::Storage;
813
814 let storage = Storage::new("data".to_string()).build();
815
816 let container = Container::new("worker".to_string())
817 .cluster("compute".to_string())
818 .code(ContainerCode::Image {
819 image: "worker:latest".to_string(),
820 })
821 .cpu(ResourceSpec {
822 min: "0.5".to_string(),
823 desired: "1".to_string(),
824 })
825 .memory(ResourceSpec {
826 min: "512Mi".to_string(),
827 desired: "1Gi".to_string(),
828 })
829 .port(8080)
830 .replicas(3)
831 .link(&storage)
832 .permissions("worker".to_string())
833 .build();
834
835 let deps = container.get_dependencies();
837 assert_eq!(deps.len(), 2);
838 }
839
840 #[test]
841 fn test_container_validate_update_immutable_cluster() {
842 let container1 = Container::new("api".to_string())
843 .cluster("cluster-1".to_string())
844 .code(ContainerCode::Image {
845 image: "myapp:v1".to_string(),
846 })
847 .cpu(ResourceSpec {
848 min: "0.5".to_string(),
849 desired: "1".to_string(),
850 })
851 .memory(ResourceSpec {
852 min: "512Mi".to_string(),
853 desired: "1Gi".to_string(),
854 })
855 .port(8080)
856 .replicas(2)
857 .permissions("execution".to_string())
858 .build();
859
860 let container2 = Container::new("api".to_string())
861 .cluster("cluster-2".to_string()) .code(ContainerCode::Image {
863 image: "myapp:v2".to_string(),
864 })
865 .cpu(ResourceSpec {
866 min: "0.5".to_string(),
867 desired: "1".to_string(),
868 })
869 .memory(ResourceSpec {
870 min: "512Mi".to_string(),
871 desired: "1Gi".to_string(),
872 })
873 .port(8080)
874 .replicas(2)
875 .permissions("execution".to_string())
876 .build();
877
878 let result = container1.validate_update(&container2);
879 assert!(result.is_err());
880 }
881
882 #[test]
883 fn test_container_validate_update_allowed_changes() {
884 let container1 = Container::new("api".to_string())
885 .cluster("compute".to_string())
886 .code(ContainerCode::Image {
887 image: "myapp:v1".to_string(),
888 })
889 .cpu(ResourceSpec {
890 min: "0.5".to_string(),
891 desired: "1".to_string(),
892 })
893 .memory(ResourceSpec {
894 min: "512Mi".to_string(),
895 desired: "1Gi".to_string(),
896 })
897 .port(8080)
898 .replicas(2)
899 .permissions("execution".to_string())
900 .build();
901
902 let container2 = Container::new("api".to_string())
903 .cluster("compute".to_string())
904 .code(ContainerCode::Image {
905 image: "myapp:v2".to_string(), })
907 .cpu(ResourceSpec {
908 min: "1".to_string(), desired: "2".to_string(),
910 })
911 .memory(ResourceSpec {
912 min: "1Gi".to_string(),
913 desired: "2Gi".to_string(),
914 })
915 .port(8080)
916 .replicas(5) .permissions("execution".to_string())
918 .build();
919
920 let result = container1.validate_update(&container2);
921 assert!(result.is_ok());
922 }
923
924 #[test]
925 fn test_container_serialization() {
926 let container = Container::new("test".to_string())
927 .cluster("compute".to_string())
928 .code(ContainerCode::Image {
929 image: "test:latest".to_string(),
930 })
931 .cpu(ResourceSpec {
932 min: "0.5".to_string(),
933 desired: "1".to_string(),
934 })
935 .memory(ResourceSpec {
936 min: "512Mi".to_string(),
937 desired: "1Gi".to_string(),
938 })
939 .port(8080)
940 .replicas(1)
941 .permissions("test".to_string())
942 .build();
943
944 let json = serde_json::to_string(&container).unwrap();
945 let deserialized: Container = serde_json::from_str(&json).unwrap();
946 assert_eq!(container, deserialized);
947 }
948
949 #[test]
950 fn test_container_multi_endpoint_validation() {
951 let container = Container::new("multi-tcp".to_string())
952 .cluster("compute".to_string())
953 .code(ContainerCode::Image {
954 image: "test:latest".to_string(),
955 })
956 .cpu(ResourceSpec {
957 min: "1".to_string(),
958 desired: "1".to_string(),
959 })
960 .memory(ResourceSpec {
961 min: "1Gi".to_string(),
962 desired: "1Gi".to_string(),
963 })
964 .port(8080)
965 .public_endpoint(PublicEndpoint {
966 name: "api".to_string(),
967 port: 8080,
968 protocol: ExposeProtocol::Http,
969 host_label: None,
970 wildcard_subdomains: false,
971 })
972 .public_endpoint(PublicEndpoint {
973 name: "wildcard".to_string(),
974 port: 8080,
975 protocol: ExposeProtocol::Http,
976 host_label: Some("wildcard".to_string()),
977 wildcard_subdomains: true,
978 })
979 .replicas(1)
980 .permissions("test".to_string())
981 .build();
982
983 assert!(container.validate_public_endpoints().is_ok());
984
985 let invalid_container = Container::new("multi-http".to_string())
986 .cluster("compute".to_string())
987 .code(ContainerCode::Image {
988 image: "test:latest".to_string(),
989 })
990 .cpu(ResourceSpec {
991 min: "1".to_string(),
992 desired: "1".to_string(),
993 })
994 .memory(ResourceSpec {
995 min: "1Gi".to_string(),
996 desired: "1Gi".to_string(),
997 })
998 .port(8080)
999 .port(9090)
1000 .public_endpoint(PublicEndpoint {
1001 name: "api".to_string(),
1002 port: 8080,
1003 protocol: ExposeProtocol::Http,
1004 host_label: None,
1005 wildcard_subdomains: false,
1006 })
1007 .public_endpoint(PublicEndpoint {
1008 name: "admin".to_string(),
1009 port: 9090,
1010 protocol: ExposeProtocol::Http,
1011 host_label: None,
1012 wildcard_subdomains: false,
1013 })
1014 .replicas(1)
1015 .permissions("test".to_string())
1016 .build();
1017
1018 assert!(invalid_container.validate_public_endpoints().is_err());
1019 }
1020
1021 #[test]
1022 fn container_rejects_multiple_apex_public_endpoints() {
1023 let container = Container::new("apex-container".to_string())
1024 .cluster("compute".to_string())
1025 .code(ContainerCode::Image {
1026 image: "test:latest".to_string(),
1027 })
1028 .cpu(ResourceSpec {
1029 min: "1".to_string(),
1030 desired: "1".to_string(),
1031 })
1032 .memory(ResourceSpec {
1033 min: "1Gi".to_string(),
1034 desired: "1Gi".to_string(),
1035 })
1036 .port(8080)
1037 .public_endpoint(PublicEndpoint {
1038 name: "web".to_string(),
1039 port: 8080,
1040 protocol: ExposeProtocol::Http,
1041 host_label: Some(APEX_HOST_LABEL.to_string()),
1042 wildcard_subdomains: false,
1043 })
1044 .public_endpoint(PublicEndpoint {
1045 name: "admin".to_string(),
1046 port: 8080,
1047 protocol: ExposeProtocol::Http,
1048 host_label: Some(APEX_HOST_LABEL.to_string()),
1049 wildcard_subdomains: false,
1050 })
1051 .replicas(1)
1052 .permissions("test".to_string())
1053 .build();
1054
1055 assert!(container.validate_public_endpoints().is_err());
1056 }
1057
1058 #[test]
1059 fn test_container_empty_ports_validation() {
1060 let container = Container::new("no-ports".to_string())
1061 .cluster("compute".to_string())
1062 .code(ContainerCode::Image {
1063 image: "test:latest".to_string(),
1064 })
1065 .cpu(ResourceSpec {
1066 min: "1".to_string(),
1067 desired: "1".to_string(),
1068 })
1069 .memory(ResourceSpec {
1070 min: "1Gi".to_string(),
1071 desired: "1Gi".to_string(),
1072 })
1073 .replicas(1)
1074 .permissions("test".to_string())
1075 .build();
1076
1077 assert!(container.validate_public_endpoints().is_ok());
1078 }
1079}