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 #[builder(default = default_commands_enabled())]
278 #[serde(default = "default_commands_enabled")]
279 #[cfg_attr(feature = "openapi", schema(default = default_commands_enabled))]
280 pub commands_enabled: bool,
281
282 #[serde(skip_serializing_if = "Option::is_none")]
287 #[cfg_attr(feature = "openapi", schema(minimum = 1, maximum = 86400))]
288 pub stop_grace_period_seconds: Option<u32>,
289}
290
291impl Container {
292 pub const RESOURCE_TYPE: ResourceType = ResourceType::from_static("container");
294
295 pub fn id(&self) -> &str {
297 &self.id
298 }
299
300 pub fn get_permissions(&self) -> &str {
302 &self.permissions
303 }
304
305 pub fn is_stateless(&self) -> bool {
307 !self.stateful
308 }
309
310 fn validate_public_endpoints(&self) -> Result<()> {
312 let mut endpoint_names = std::collections::HashSet::new();
313 let mut backend_ports = std::collections::HashSet::new();
314 let mut apex_endpoint_name: Option<&str> = None;
315
316 for endpoint in &self.public_endpoints {
317 endpoint.validate_for_resource(&self.id)?;
318
319 if !endpoint_names.insert(endpoint.name.as_str()) {
320 return Err(AlienError::new(ErrorData::InvalidResourceUpdate {
321 resource_id: self.id.clone(),
322 reason: format!("duplicate public endpoint name '{}'", endpoint.name),
323 }));
324 }
325
326 if endpoint.host_label.as_deref() == Some(APEX_HOST_LABEL) {
327 if let Some(existing_name) = apex_endpoint_name {
328 return Err(AlienError::new(ErrorData::InvalidResourceUpdate {
329 resource_id: self.id.clone(),
330 reason: format!(
331 "only one apex public endpoint is allowed per resource; '{}' already uses hostLabel '@'",
332 existing_name
333 ),
334 }));
335 }
336 apex_endpoint_name = Some(endpoint.name.as_str());
337 }
338
339 backend_ports.insert(endpoint.port);
340
341 if !self.ports.iter().any(|port| port.port == endpoint.port) {
342 return Err(AlienError::new(ErrorData::InvalidResourceUpdate {
343 resource_id: self.id.clone(),
344 reason: format!(
345 "public endpoint '{}' references undeclared port {}",
346 endpoint.name, endpoint.port
347 ),
348 }));
349 }
350 }
351
352 if backend_ports.len() > 1 {
353 return Err(AlienError::new(ErrorData::InvalidResourceUpdate {
354 resource_id: self.id.clone(),
355 reason:
356 "public endpoints on one container must currently route to the same backend port"
357 .to_string(),
358 }));
359 }
360
361 Ok(())
362 }
363}
364
365fn default_commands_enabled() -> bool {
366 false
367}
368
369impl<S: container_builder::State> ContainerBuilder<S> {
370 pub fn link<R: ?Sized>(mut self, resource: &R) -> Self
372 where
373 for<'a> &'a R: Into<ResourceRef>,
374 {
375 let resource_ref: ResourceRef = resource.into();
376 self.links.push(resource_ref);
377 self
378 }
379
380 pub fn port(mut self, port: u16) -> Self {
382 self.ports.push(ContainerPort { port });
383 self
384 }
385
386 pub fn public_endpoint(mut self, endpoint: PublicEndpoint) -> Self {
388 if !self.ports.iter().any(|p| p.port == endpoint.port) {
389 self.ports.push(ContainerPort {
390 port: endpoint.port,
391 });
392 }
393 self.public_endpoints.push(endpoint);
394 self
395 }
396}
397
398#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
400#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
401#[serde(rename_all = "camelCase")]
402pub enum ContainerStatus {
403 Pending,
405 Running,
407 Stopped,
409 Failing,
412}
413
414#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
416#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
417#[serde(rename_all = "camelCase")]
418pub struct ReplicaStatus {
419 pub replica_id: String,
421 pub ordinal: Option<u32>,
423 pub machine_id: Option<String>,
425 pub healthy: bool,
427 pub container_ip: Option<String>,
429}
430
431#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
433#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
434#[serde(rename_all = "camelCase")]
435pub struct ContainerOutputs {
436 pub name: String,
438 pub status: ContainerStatus,
440 pub current_replicas: u32,
442 pub desired_replicas: u32,
444 pub internal_dns: String,
446 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
448 pub public_endpoints: HashMap<String, PublicEndpointOutput>,
449 pub replicas: Vec<ReplicaStatus>,
451}
452
453impl ResourceOutputsDefinition for ContainerOutputs {
454 fn get_resource_type(&self) -> ResourceType {
455 Container::RESOURCE_TYPE.clone()
456 }
457
458 fn as_any(&self) -> &dyn Any {
459 self
460 }
461
462 fn box_clone(&self) -> Box<dyn ResourceOutputsDefinition> {
463 Box::new(self.clone())
464 }
465
466 fn outputs_eq(&self, other: &dyn ResourceOutputsDefinition) -> bool {
467 other.as_any().downcast_ref::<ContainerOutputs>() == Some(self)
468 }
469
470 fn to_json_value(&self) -> serde_json::Result<serde_json::Value> {
471 serde_json::to_value(self)
472 }
473}
474
475impl ResourceDefinition for Container {
476 fn get_resource_type(&self) -> ResourceType {
477 Self::RESOURCE_TYPE
478 }
479
480 fn id(&self) -> &str {
481 &self.id
482 }
483
484 fn get_dependencies(&self) -> Vec<ResourceRef> {
485 let mut deps = self.links.clone();
486 if let Some(cluster) = &self.cluster {
489 deps.push(ResourceRef::new(
490 ComputeCluster::RESOURCE_TYPE.clone(),
491 cluster,
492 ));
493 }
494 deps
495 }
496
497 fn get_permissions(&self) -> Option<&str> {
498 Some(&self.permissions)
499 }
500
501 fn validate_update(&self, new_config: &dyn ResourceDefinition) -> Result<()> {
502 let new_container = new_config
503 .as_any()
504 .downcast_ref::<Container>()
505 .ok_or_else(|| {
506 AlienError::new(ErrorData::UnexpectedResourceType {
507 resource_id: self.id.clone(),
508 expected: Self::RESOURCE_TYPE,
509 actual: new_config.get_resource_type(),
510 })
511 })?;
512
513 new_container.validate_public_endpoints()?;
515
516 if self.id != new_container.id {
517 return Err(AlienError::new(ErrorData::InvalidResourceUpdate {
518 resource_id: self.id.clone(),
519 reason: "the 'id' field is immutable".to_string(),
520 }));
521 }
522
523 if self.cluster != new_container.cluster {
525 return Err(AlienError::new(ErrorData::InvalidResourceUpdate {
526 resource_id: self.id.clone(),
527 reason: "the 'cluster' field is immutable".to_string(),
528 }));
529 }
530
531 if self.stateful != new_container.stateful {
533 return Err(AlienError::new(ErrorData::InvalidResourceUpdate {
534 resource_id: self.id.clone(),
535 reason: "the 'stateful' field is immutable".to_string(),
536 }));
537 }
538
539 if self.ports != new_container.ports {
541 return Err(AlienError::new(ErrorData::InvalidResourceUpdate {
542 resource_id: self.id.clone(),
543 reason: "the 'ports' field is immutable".to_string(),
544 }));
545 }
546
547 if self.public_endpoints != new_container.public_endpoints {
548 return Err(AlienError::new(ErrorData::InvalidResourceUpdate {
549 resource_id: self.id.clone(),
550 reason: "the 'publicEndpoints' field is immutable".to_string(),
551 }));
552 }
553
554 if self.pool != new_container.pool {
556 return Err(AlienError::new(ErrorData::InvalidResourceUpdate {
557 resource_id: self.id.clone(),
558 reason: "the 'pool' field is immutable".to_string(),
559 }));
560 }
561
562 Ok(())
563 }
564
565 fn as_any(&self) -> &dyn Any {
566 self
567 }
568
569 fn as_any_mut(&mut self) -> &mut dyn Any {
570 self
571 }
572
573 fn box_clone(&self) -> Box<dyn ResourceDefinition> {
574 Box::new(self.clone())
575 }
576
577 fn resource_eq(&self, other: &dyn ResourceDefinition) -> bool {
578 other.as_any().downcast_ref::<Container>() == Some(self)
579 }
580
581 fn to_json_value(&self) -> serde_json::Result<serde_json::Value> {
582 serde_json::to_value(self)
583 }
584}
585
586#[cfg(test)]
587mod tests {
588 use super::*;
589 use crate::resources::ExposeProtocol;
590
591 #[test]
592 fn test_container_creation_with_autoscaling() {
593 let container = Container::new("api".to_string())
594 .cluster("compute".to_string())
595 .code(ContainerCode::Image {
596 image: "myapp:latest".to_string(),
597 })
598 .cpu(ResourceSpec {
599 min: "0.5".to_string(),
600 desired: "1".to_string(),
601 })
602 .memory(ResourceSpec {
603 min: "512Mi".to_string(),
604 desired: "1Gi".to_string(),
605 })
606 .port(8080)
607 .public_endpoint(PublicEndpoint {
608 name: "api".to_string(),
609 port: 8080,
610 protocol: ExposeProtocol::Http,
611 host_label: None,
612 wildcard_subdomains: false,
613 })
614 .autoscaling(ContainerAutoscaling {
615 min: 2,
616 desired: 3,
617 max: 10,
618 target_cpu_percent: Some(70.0),
619 target_memory_percent: None,
620 target_http_in_flight_per_replica: Some(100),
621 max_http_p95_latency_ms: None,
622 })
623 .permissions("container-execution".to_string())
624 .build();
625
626 assert_eq!(container.id(), "api");
627 assert_eq!(container.cluster, Some("compute".to_string()));
628 assert!(!container.stateful);
629 assert!(container.autoscaling.is_some());
630 assert_eq!(container.ports.len(), 1);
631 assert_eq!(container.ports[0].port, 8080);
632 }
633
634 #[test]
635 fn container_serializes_stop_grace_period_when_set() {
636 let container = Container::new("api".to_string())
637 .cluster("compute".to_string())
638 .code(ContainerCode::Image {
639 image: "myapp:latest".to_string(),
640 })
641 .cpu(ResourceSpec {
642 min: "0.5".to_string(),
643 desired: "1".to_string(),
644 })
645 .memory(ResourceSpec {
646 min: "512Mi".to_string(),
647 desired: "1Gi".to_string(),
648 })
649 .port(8080)
650 .permissions("container-execution".to_string())
651 .stop_grace_period_seconds(21_600)
652 .build();
653
654 let json = serde_json::to_value(&container).expect("container should serialize");
655 assert_eq!(json["stopGracePeriodSeconds"], 21_600);
656 }
657
658 #[test]
659 fn container_omits_stop_grace_period_when_absent() {
660 let container = Container::new("api".to_string())
661 .cluster("compute".to_string())
662 .code(ContainerCode::Image {
663 image: "myapp:latest".to_string(),
664 })
665 .cpu(ResourceSpec {
666 min: "0.5".to_string(),
667 desired: "1".to_string(),
668 })
669 .memory(ResourceSpec {
670 min: "512Mi".to_string(),
671 desired: "1Gi".to_string(),
672 })
673 .port(8080)
674 .permissions("container-execution".to_string())
675 .build();
676
677 let json = serde_json::to_value(&container).expect("container should serialize");
678 assert!(json.get("stopGracePeriodSeconds").is_none());
679 }
680
681 #[test]
682 fn test_stateful_container_with_storage() {
683 let container = Container::new("postgres".to_string())
684 .cluster("compute".to_string())
685 .code(ContainerCode::Image {
686 image: "postgres:16".to_string(),
687 })
688 .cpu(ResourceSpec {
689 min: "1".to_string(),
690 desired: "2".to_string(),
691 })
692 .memory(ResourceSpec {
693 min: "2Gi".to_string(),
694 desired: "4Gi".to_string(),
695 })
696 .port(5432)
697 .stateful(true)
698 .replicas(1)
699 .persistent_storage(PersistentStorage {
700 size: "100Gi".to_string(),
701 mount_path: "/var/lib/postgresql/data".to_string(),
702 })
703 .permissions("database".to_string())
704 .build();
705
706 assert_eq!(container.id(), "postgres");
707 assert!(container.stateful);
708 assert!(container.replicas.is_some());
709 assert!(container.persistent_storage.is_some());
710 }
711
712 #[test]
713 fn test_public_container() {
714 let container = Container::new("frontend".to_string())
715 .cluster("compute".to_string())
716 .code(ContainerCode::Image {
717 image: "frontend:latest".to_string(),
718 })
719 .cpu(ResourceSpec {
720 min: "0.25".to_string(),
721 desired: "0.5".to_string(),
722 })
723 .memory(ResourceSpec {
724 min: "256Mi".to_string(),
725 desired: "512Mi".to_string(),
726 })
727 .port(3000)
728 .public_endpoint(PublicEndpoint {
729 name: "web".to_string(),
730 port: 3000,
731 protocol: ExposeProtocol::Http,
732 host_label: None,
733 wildcard_subdomains: false,
734 })
735 .autoscaling(ContainerAutoscaling {
736 min: 2,
737 desired: 2,
738 max: 20,
739 target_cpu_percent: None,
740 target_memory_percent: None,
741 target_http_in_flight_per_replica: Some(50),
742 max_http_p95_latency_ms: Some(100.0),
743 })
744 .health_check(HealthCheck {
745 path: "/health".to_string(),
746 port: None,
747 method: "GET".to_string(),
748 timeout_seconds: 1,
749 failure_threshold: 3,
750 })
751 .permissions("frontend".to_string())
752 .build();
753
754 assert_eq!(container.ports[0].port, 3000);
755 assert_eq!(container.public_endpoints[0].name, "web");
756 assert!(container.health_check.is_some());
757 }
758
759 #[test]
760 fn test_public_container_endpoint_options() {
761 let container = Container::new("router".to_string())
762 .cluster("compute".to_string())
763 .code(ContainerCode::Image {
764 image: "router:latest".to_string(),
765 })
766 .cpu(ResourceSpec {
767 min: "0.25".to_string(),
768 desired: "0.5".to_string(),
769 })
770 .memory(ResourceSpec {
771 min: "256Mi".to_string(),
772 desired: "512Mi".to_string(),
773 })
774 .public_endpoint(PublicEndpoint {
775 name: "gateway".to_string(),
776 port: 8080,
777 protocol: ExposeProtocol::Http,
778 host_label: Some("gateway".to_string()),
779 wildcard_subdomains: true,
780 })
781 .permissions("router".to_string())
782 .build();
783
784 assert!(container.validate_public_endpoints().is_ok());
785 assert_eq!(container.ports.len(), 1);
786 assert_eq!(container.public_endpoints.len(), 1);
787 assert_eq!(
788 container.public_endpoints[0].host_label.as_deref(),
789 Some("gateway")
790 );
791 assert!(container.public_endpoints[0].wildcard_subdomains);
792 }
793
794 #[test]
795 fn test_public_container_rejects_invalid_host_label() {
796 let container = Container::new("router".to_string())
797 .cluster("compute".to_string())
798 .code(ContainerCode::Image {
799 image: "router:latest".to_string(),
800 })
801 .cpu(ResourceSpec {
802 min: "0.25".to_string(),
803 desired: "0.5".to_string(),
804 })
805 .memory(ResourceSpec {
806 min: "256Mi".to_string(),
807 desired: "512Mi".to_string(),
808 })
809 .public_endpoint(PublicEndpoint {
810 name: "gateway".to_string(),
811 port: 8080,
812 protocol: ExposeProtocol::Http,
813 host_label: Some("bad.label".to_string()),
814 wildcard_subdomains: true,
815 })
816 .permissions("router".to_string())
817 .build();
818
819 assert!(container.validate_public_endpoints().is_err());
820 }
821
822 #[test]
823 fn test_container_with_links() {
824 use crate::Storage;
825
826 let storage = Storage::new("data".to_string()).build();
827
828 let container = Container::new("worker".to_string())
829 .cluster("compute".to_string())
830 .code(ContainerCode::Image {
831 image: "worker:latest".to_string(),
832 })
833 .cpu(ResourceSpec {
834 min: "0.5".to_string(),
835 desired: "1".to_string(),
836 })
837 .memory(ResourceSpec {
838 min: "512Mi".to_string(),
839 desired: "1Gi".to_string(),
840 })
841 .port(8080)
842 .replicas(3)
843 .link(&storage)
844 .permissions("worker".to_string())
845 .build();
846
847 let deps = container.get_dependencies();
849 assert_eq!(deps.len(), 2);
850 }
851
852 #[test]
853 fn test_container_validate_update_immutable_cluster() {
854 let container1 = Container::new("api".to_string())
855 .cluster("cluster-1".to_string())
856 .code(ContainerCode::Image {
857 image: "myapp:v1".to_string(),
858 })
859 .cpu(ResourceSpec {
860 min: "0.5".to_string(),
861 desired: "1".to_string(),
862 })
863 .memory(ResourceSpec {
864 min: "512Mi".to_string(),
865 desired: "1Gi".to_string(),
866 })
867 .port(8080)
868 .replicas(2)
869 .permissions("execution".to_string())
870 .build();
871
872 let container2 = Container::new("api".to_string())
873 .cluster("cluster-2".to_string()) .code(ContainerCode::Image {
875 image: "myapp:v2".to_string(),
876 })
877 .cpu(ResourceSpec {
878 min: "0.5".to_string(),
879 desired: "1".to_string(),
880 })
881 .memory(ResourceSpec {
882 min: "512Mi".to_string(),
883 desired: "1Gi".to_string(),
884 })
885 .port(8080)
886 .replicas(2)
887 .permissions("execution".to_string())
888 .build();
889
890 let result = container1.validate_update(&container2);
891 assert!(result.is_err());
892 }
893
894 #[test]
895 fn test_container_validate_update_allowed_changes() {
896 let container1 = Container::new("api".to_string())
897 .cluster("compute".to_string())
898 .code(ContainerCode::Image {
899 image: "myapp:v1".to_string(),
900 })
901 .cpu(ResourceSpec {
902 min: "0.5".to_string(),
903 desired: "1".to_string(),
904 })
905 .memory(ResourceSpec {
906 min: "512Mi".to_string(),
907 desired: "1Gi".to_string(),
908 })
909 .port(8080)
910 .replicas(2)
911 .permissions("execution".to_string())
912 .build();
913
914 let container2 = Container::new("api".to_string())
915 .cluster("compute".to_string())
916 .code(ContainerCode::Image {
917 image: "myapp:v2".to_string(), })
919 .cpu(ResourceSpec {
920 min: "1".to_string(), desired: "2".to_string(),
922 })
923 .memory(ResourceSpec {
924 min: "1Gi".to_string(),
925 desired: "2Gi".to_string(),
926 })
927 .port(8080)
928 .replicas(5) .permissions("execution".to_string())
930 .build();
931
932 let result = container1.validate_update(&container2);
933 assert!(result.is_ok());
934 }
935
936 #[test]
937 fn test_container_serialization() {
938 let container = Container::new("test".to_string())
939 .cluster("compute".to_string())
940 .code(ContainerCode::Image {
941 image: "test:latest".to_string(),
942 })
943 .cpu(ResourceSpec {
944 min: "0.5".to_string(),
945 desired: "1".to_string(),
946 })
947 .memory(ResourceSpec {
948 min: "512Mi".to_string(),
949 desired: "1Gi".to_string(),
950 })
951 .port(8080)
952 .replicas(1)
953 .permissions("test".to_string())
954 .build();
955
956 let json = serde_json::to_string(&container).unwrap();
957 let deserialized: Container = serde_json::from_str(&json).unwrap();
958 assert_eq!(container, deserialized);
959 }
960
961 #[test]
962 fn test_container_multi_endpoint_validation() {
963 let container = Container::new("multi-tcp".to_string())
964 .cluster("compute".to_string())
965 .code(ContainerCode::Image {
966 image: "test:latest".to_string(),
967 })
968 .cpu(ResourceSpec {
969 min: "1".to_string(),
970 desired: "1".to_string(),
971 })
972 .memory(ResourceSpec {
973 min: "1Gi".to_string(),
974 desired: "1Gi".to_string(),
975 })
976 .port(8080)
977 .public_endpoint(PublicEndpoint {
978 name: "api".to_string(),
979 port: 8080,
980 protocol: ExposeProtocol::Http,
981 host_label: None,
982 wildcard_subdomains: false,
983 })
984 .public_endpoint(PublicEndpoint {
985 name: "wildcard".to_string(),
986 port: 8080,
987 protocol: ExposeProtocol::Http,
988 host_label: Some("wildcard".to_string()),
989 wildcard_subdomains: true,
990 })
991 .replicas(1)
992 .permissions("test".to_string())
993 .build();
994
995 assert!(container.validate_public_endpoints().is_ok());
996
997 let invalid_container = Container::new("multi-http".to_string())
998 .cluster("compute".to_string())
999 .code(ContainerCode::Image {
1000 image: "test:latest".to_string(),
1001 })
1002 .cpu(ResourceSpec {
1003 min: "1".to_string(),
1004 desired: "1".to_string(),
1005 })
1006 .memory(ResourceSpec {
1007 min: "1Gi".to_string(),
1008 desired: "1Gi".to_string(),
1009 })
1010 .port(8080)
1011 .port(9090)
1012 .public_endpoint(PublicEndpoint {
1013 name: "api".to_string(),
1014 port: 8080,
1015 protocol: ExposeProtocol::Http,
1016 host_label: None,
1017 wildcard_subdomains: false,
1018 })
1019 .public_endpoint(PublicEndpoint {
1020 name: "admin".to_string(),
1021 port: 9090,
1022 protocol: ExposeProtocol::Http,
1023 host_label: None,
1024 wildcard_subdomains: false,
1025 })
1026 .replicas(1)
1027 .permissions("test".to_string())
1028 .build();
1029
1030 assert!(invalid_container.validate_public_endpoints().is_err());
1031 }
1032
1033 #[test]
1034 fn container_rejects_multiple_apex_public_endpoints() {
1035 let container = Container::new("apex-container".to_string())
1036 .cluster("compute".to_string())
1037 .code(ContainerCode::Image {
1038 image: "test:latest".to_string(),
1039 })
1040 .cpu(ResourceSpec {
1041 min: "1".to_string(),
1042 desired: "1".to_string(),
1043 })
1044 .memory(ResourceSpec {
1045 min: "1Gi".to_string(),
1046 desired: "1Gi".to_string(),
1047 })
1048 .port(8080)
1049 .public_endpoint(PublicEndpoint {
1050 name: "web".to_string(),
1051 port: 8080,
1052 protocol: ExposeProtocol::Http,
1053 host_label: Some(APEX_HOST_LABEL.to_string()),
1054 wildcard_subdomains: false,
1055 })
1056 .public_endpoint(PublicEndpoint {
1057 name: "admin".to_string(),
1058 port: 8080,
1059 protocol: ExposeProtocol::Http,
1060 host_label: Some(APEX_HOST_LABEL.to_string()),
1061 wildcard_subdomains: false,
1062 })
1063 .replicas(1)
1064 .permissions("test".to_string())
1065 .build();
1066
1067 assert!(container.validate_public_endpoints().is_err());
1068 }
1069
1070 #[test]
1071 fn test_container_empty_ports_validation() {
1072 let container = Container::new("no-ports".to_string())
1073 .cluster("compute".to_string())
1074 .code(ContainerCode::Image {
1075 image: "test:latest".to_string(),
1076 })
1077 .cpu(ResourceSpec {
1078 min: "1".to_string(),
1079 desired: "1".to_string(),
1080 })
1081 .memory(ResourceSpec {
1082 min: "1Gi".to_string(),
1083 desired: "1Gi".to_string(),
1084 })
1085 .replicas(1)
1086 .permissions("test".to_string())
1087 .build();
1088
1089 assert!(container.validate_public_endpoints().is_ok());
1090 }
1091
1092 #[test]
1093 fn test_container_commands_enabled_defaults_false() {
1094 let container = Container::new("no-commands".to_string())
1095 .cluster("compute".to_string())
1096 .code(ContainerCode::Image {
1097 image: "test:latest".to_string(),
1098 })
1099 .cpu(ResourceSpec {
1100 min: "0.5".to_string(),
1101 desired: "1".to_string(),
1102 })
1103 .memory(ResourceSpec {
1104 min: "512Mi".to_string(),
1105 desired: "1Gi".to_string(),
1106 })
1107 .port(8080)
1108 .permissions("test".to_string())
1109 .build();
1110
1111 assert!(!container.commands_enabled);
1112 }
1113
1114 #[test]
1115 fn test_container_commands_enabled_builder() {
1116 let container = Container::new("cmd-container".to_string())
1117 .cluster("compute".to_string())
1118 .code(ContainerCode::Image {
1119 image: "test:latest".to_string(),
1120 })
1121 .cpu(ResourceSpec {
1122 min: "0.5".to_string(),
1123 desired: "1".to_string(),
1124 })
1125 .memory(ResourceSpec {
1126 min: "512Mi".to_string(),
1127 desired: "1Gi".to_string(),
1128 })
1129 .port(8080)
1130 .permissions("test".to_string())
1131 .commands_enabled(true)
1132 .build();
1133
1134 assert!(container.commands_enabled);
1135 }
1136
1137 #[test]
1138 fn test_container_commands_enabled_serializes_camel_case() {
1139 let container = Container::new("cmd-container".to_string())
1140 .cluster("compute".to_string())
1141 .code(ContainerCode::Image {
1142 image: "test:latest".to_string(),
1143 })
1144 .cpu(ResourceSpec {
1145 min: "0.5".to_string(),
1146 desired: "1".to_string(),
1147 })
1148 .memory(ResourceSpec {
1149 min: "512Mi".to_string(),
1150 desired: "1Gi".to_string(),
1151 })
1152 .port(8080)
1153 .permissions("test".to_string())
1154 .commands_enabled(true)
1155 .build();
1156
1157 let json = serde_json::to_value(&container).expect("container should serialize");
1158 assert_eq!(json["commandsEnabled"], true);
1159
1160 let deserialized: Container =
1161 serde_json::from_value(json).expect("container should deserialize");
1162 assert_eq!(deserialized, container);
1163 }
1164}