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