1use crate::error::{ErrorData, Result};
15use crate::resource::{ResourceDefinition, ResourceOutputsDefinition, ResourceRef, ResourceType};
16use crate::resources::{ComputeCluster, PublicEndpoint, PublicEndpointOutput, ToolchainConfig};
17use alien_error::AlienError;
18use bon::Builder;
19use serde::{Deserialize, Serialize};
20use std::any::Any;
21use std::collections::HashMap;
22use std::fmt::Debug;
23
24#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
26#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
27#[serde(rename_all = "camelCase", tag = "type")]
28pub enum ContainerCode {
29 #[serde(rename_all = "camelCase")]
31 Image {
32 image: String,
34 },
35 #[serde(rename_all = "camelCase")]
37 Source {
38 src: String,
40 toolchain: ToolchainConfig,
42 },
43}
44
45#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
47#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
48#[serde(rename_all = "camelCase")]
49pub struct ResourceSpec {
50 pub min: String,
52 pub desired: String,
54}
55
56#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
58#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
59#[serde(rename_all = "camelCase")]
60pub struct ContainerGpuSpec {
61 #[serde(rename = "type")]
63 pub gpu_type: String,
64 pub count: u32,
66}
67
68#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
70#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
71#[serde(rename_all = "camelCase")]
72pub struct PersistentStorage {
73 pub size: String,
75 pub mount_path: String,
77}
78
79#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
81#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
82#[serde(rename_all = "camelCase")]
83pub struct ContainerAutoscaling {
84 pub min: u32,
86 pub desired: u32,
88 pub max: u32,
90 #[serde(skip_serializing_if = "Option::is_none")]
92 pub target_cpu_percent: Option<f64>,
93 #[serde(skip_serializing_if = "Option::is_none")]
95 pub target_memory_percent: Option<f64>,
96 #[serde(skip_serializing_if = "Option::is_none")]
98 pub target_http_in_flight_per_replica: Option<u32>,
99 #[serde(skip_serializing_if = "Option::is_none")]
101 pub max_http_p95_latency_ms: Option<f64>,
102}
103
104#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
106#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
107#[serde(rename_all = "camelCase")]
108pub struct HealthCheck {
109 #[serde(default = "default_health_path")]
111 pub path: String,
112 #[serde(skip_serializing_if = "Option::is_none")]
114 pub port: Option<u16>,
115 #[serde(default = "default_health_method")]
117 pub method: String,
118 #[serde(default = "default_timeout_seconds")]
120 pub timeout_seconds: u32,
121 #[serde(default = "default_failure_threshold")]
123 pub failure_threshold: u32,
124}
125
126fn default_health_path() -> String {
127 "/health".to_string()
128}
129
130fn default_health_method() -> String {
131 "GET".to_string()
132}
133
134fn default_timeout_seconds() -> u32 {
135 1
136}
137
138fn default_failure_threshold() -> u32 {
139 3
140}
141
142#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
144#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
145#[serde(rename_all = "camelCase")]
146pub struct ContainerPort {
147 pub port: u16,
149}
150
151#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Builder)]
190#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
191#[serde(rename_all = "camelCase", deny_unknown_fields)]
192#[builder(start_fn = new)]
193pub struct Container {
194 #[builder(start_fn)]
197 pub id: String,
198
199 #[builder(field)]
201 pub links: Vec<ResourceRef>,
202
203 #[builder(field)]
205 pub ports: Vec<ContainerPort>,
206
207 #[builder(field)]
209 #[serde(default, skip_serializing_if = "Vec::is_empty")]
210 pub public_endpoints: Vec<PublicEndpoint>,
211
212 #[serde(skip_serializing_if = "Option::is_none")]
215 pub cluster: Option<String>,
216
217 pub code: ContainerCode,
219
220 pub cpu: ResourceSpec,
222
223 pub memory: ResourceSpec,
225
226 #[serde(skip_serializing_if = "Option::is_none")]
228 pub gpu: Option<ContainerGpuSpec>,
229
230 #[serde(skip_serializing_if = "Option::is_none")]
232 pub ephemeral_storage: Option<String>,
233
234 #[serde(skip_serializing_if = "Option::is_none")]
236 pub persistent_storage: Option<PersistentStorage>,
237
238 #[serde(skip_serializing_if = "Option::is_none")]
240 pub replicas: Option<u32>,
241
242 #[serde(skip_serializing_if = "Option::is_none")]
244 pub autoscaling: Option<ContainerAutoscaling>,
245
246 #[builder(default = false)]
248 #[serde(default)]
249 pub stateful: bool,
250
251 #[builder(default)]
253 #[serde(default)]
254 pub environment: HashMap<String, String>,
255
256 #[serde(skip_serializing_if = "Option::is_none")]
259 pub pool: Option<String>,
260
261 pub permissions: String,
263
264 #[serde(skip_serializing_if = "Option::is_none")]
266 pub health_check: Option<HealthCheck>,
267
268 #[serde(skip_serializing_if = "Option::is_none")]
270 pub command: Option<Vec<String>>,
271}
272
273impl Container {
274 pub const RESOURCE_TYPE: ResourceType = ResourceType::from_static("container");
276
277 pub fn id(&self) -> &str {
279 &self.id
280 }
281
282 pub fn get_permissions(&self) -> &str {
284 &self.permissions
285 }
286
287 pub fn is_stateless(&self) -> bool {
289 !self.stateful
290 }
291
292 fn validate_public_endpoints(&self) -> Result<()> {
294 let mut endpoint_names = std::collections::HashSet::new();
295 let mut backend_ports = std::collections::HashSet::new();
296
297 for endpoint in &self.public_endpoints {
298 endpoint.validate_for_resource(&self.id)?;
299
300 if !endpoint_names.insert(endpoint.name.as_str()) {
301 return Err(AlienError::new(ErrorData::InvalidResourceUpdate {
302 resource_id: self.id.clone(),
303 reason: format!("duplicate public endpoint name '{}'", endpoint.name),
304 }));
305 }
306
307 backend_ports.insert(endpoint.port);
308
309 if !self.ports.iter().any(|port| port.port == endpoint.port) {
310 return Err(AlienError::new(ErrorData::InvalidResourceUpdate {
311 resource_id: self.id.clone(),
312 reason: format!(
313 "public endpoint '{}' references undeclared port {}",
314 endpoint.name, endpoint.port
315 ),
316 }));
317 }
318 }
319
320 if backend_ports.len() > 1 {
321 return Err(AlienError::new(ErrorData::InvalidResourceUpdate {
322 resource_id: self.id.clone(),
323 reason:
324 "public endpoints on one container must currently route to the same backend port"
325 .to_string(),
326 }));
327 }
328
329 Ok(())
330 }
331}
332
333impl<S: container_builder::State> ContainerBuilder<S> {
334 pub fn link<R: ?Sized>(mut self, resource: &R) -> Self
336 where
337 for<'a> &'a R: Into<ResourceRef>,
338 {
339 let resource_ref: ResourceRef = resource.into();
340 self.links.push(resource_ref);
341 self
342 }
343
344 pub fn port(mut self, port: u16) -> Self {
346 self.ports.push(ContainerPort { port });
347 self
348 }
349
350 pub fn public_endpoint(mut self, endpoint: PublicEndpoint) -> Self {
352 if !self.ports.iter().any(|p| p.port == endpoint.port) {
353 self.ports.push(ContainerPort {
354 port: endpoint.port,
355 });
356 }
357 self.public_endpoints.push(endpoint);
358 self
359 }
360}
361
362#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
364#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
365#[serde(rename_all = "camelCase")]
366pub enum ContainerStatus {
367 Pending,
369 Running,
371 Stopped,
373 Failing,
376}
377
378#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
380#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
381#[serde(rename_all = "camelCase")]
382pub struct ReplicaStatus {
383 pub replica_id: String,
385 pub ordinal: Option<u32>,
387 pub machine_id: Option<String>,
389 pub healthy: bool,
391 pub container_ip: Option<String>,
393}
394
395#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
397#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
398#[serde(rename_all = "camelCase")]
399pub struct ContainerOutputs {
400 pub name: String,
402 pub status: ContainerStatus,
404 pub current_replicas: u32,
406 pub desired_replicas: u32,
408 pub internal_dns: String,
410 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
412 pub public_endpoints: HashMap<String, PublicEndpointOutput>,
413 pub replicas: Vec<ReplicaStatus>,
415}
416
417impl ResourceOutputsDefinition for ContainerOutputs {
418 fn get_resource_type(&self) -> ResourceType {
419 Container::RESOURCE_TYPE.clone()
420 }
421
422 fn as_any(&self) -> &dyn Any {
423 self
424 }
425
426 fn box_clone(&self) -> Box<dyn ResourceOutputsDefinition> {
427 Box::new(self.clone())
428 }
429
430 fn outputs_eq(&self, other: &dyn ResourceOutputsDefinition) -> bool {
431 other.as_any().downcast_ref::<ContainerOutputs>() == Some(self)
432 }
433
434 fn to_json_value(&self) -> serde_json::Result<serde_json::Value> {
435 serde_json::to_value(self)
436 }
437}
438
439impl ResourceDefinition for Container {
440 fn get_resource_type(&self) -> ResourceType {
441 Self::RESOURCE_TYPE
442 }
443
444 fn id(&self) -> &str {
445 &self.id
446 }
447
448 fn get_dependencies(&self) -> Vec<ResourceRef> {
449 let mut deps = self.links.clone();
450 if let Some(cluster) = &self.cluster {
453 deps.push(ResourceRef::new(
454 ComputeCluster::RESOURCE_TYPE.clone(),
455 cluster,
456 ));
457 }
458 deps
459 }
460
461 fn get_permissions(&self) -> Option<&str> {
462 Some(&self.permissions)
463 }
464
465 fn validate_update(&self, new_config: &dyn ResourceDefinition) -> Result<()> {
466 let new_container = new_config
467 .as_any()
468 .downcast_ref::<Container>()
469 .ok_or_else(|| {
470 AlienError::new(ErrorData::UnexpectedResourceType {
471 resource_id: self.id.clone(),
472 expected: Self::RESOURCE_TYPE,
473 actual: new_config.get_resource_type(),
474 })
475 })?;
476
477 new_container.validate_public_endpoints()?;
479
480 if self.id != new_container.id {
481 return Err(AlienError::new(ErrorData::InvalidResourceUpdate {
482 resource_id: self.id.clone(),
483 reason: "the 'id' field is immutable".to_string(),
484 }));
485 }
486
487 if self.cluster != new_container.cluster {
489 return Err(AlienError::new(ErrorData::InvalidResourceUpdate {
490 resource_id: self.id.clone(),
491 reason: "the 'cluster' field is immutable".to_string(),
492 }));
493 }
494
495 if self.stateful != new_container.stateful {
497 return Err(AlienError::new(ErrorData::InvalidResourceUpdate {
498 resource_id: self.id.clone(),
499 reason: "the 'stateful' field is immutable".to_string(),
500 }));
501 }
502
503 if self.ports != new_container.ports {
505 return Err(AlienError::new(ErrorData::InvalidResourceUpdate {
506 resource_id: self.id.clone(),
507 reason: "the 'ports' field is immutable".to_string(),
508 }));
509 }
510
511 if self.public_endpoints != new_container.public_endpoints {
512 return Err(AlienError::new(ErrorData::InvalidResourceUpdate {
513 resource_id: self.id.clone(),
514 reason: "the 'publicEndpoints' field is immutable".to_string(),
515 }));
516 }
517
518 if self.pool != new_container.pool {
520 return Err(AlienError::new(ErrorData::InvalidResourceUpdate {
521 resource_id: self.id.clone(),
522 reason: "the 'pool' field is immutable".to_string(),
523 }));
524 }
525
526 Ok(())
527 }
528
529 fn as_any(&self) -> &dyn Any {
530 self
531 }
532
533 fn as_any_mut(&mut self) -> &mut dyn Any {
534 self
535 }
536
537 fn box_clone(&self) -> Box<dyn ResourceDefinition> {
538 Box::new(self.clone())
539 }
540
541 fn resource_eq(&self, other: &dyn ResourceDefinition) -> bool {
542 other.as_any().downcast_ref::<Container>() == Some(self)
543 }
544
545 fn to_json_value(&self) -> serde_json::Result<serde_json::Value> {
546 serde_json::to_value(self)
547 }
548}
549
550#[cfg(test)]
551mod tests {
552 use super::*;
553 use crate::resources::ExposeProtocol;
554
555 #[test]
556 fn test_container_creation_with_autoscaling() {
557 let container = Container::new("api".to_string())
558 .cluster("compute".to_string())
559 .code(ContainerCode::Image {
560 image: "myapp:latest".to_string(),
561 })
562 .cpu(ResourceSpec {
563 min: "0.5".to_string(),
564 desired: "1".to_string(),
565 })
566 .memory(ResourceSpec {
567 min: "512Mi".to_string(),
568 desired: "1Gi".to_string(),
569 })
570 .port(8080)
571 .public_endpoint(PublicEndpoint {
572 name: "api".to_string(),
573 port: 8080,
574 protocol: ExposeProtocol::Http,
575 host_label: None,
576 wildcard_subdomains: false,
577 })
578 .autoscaling(ContainerAutoscaling {
579 min: 2,
580 desired: 3,
581 max: 10,
582 target_cpu_percent: Some(70.0),
583 target_memory_percent: None,
584 target_http_in_flight_per_replica: Some(100),
585 max_http_p95_latency_ms: None,
586 })
587 .permissions("container-execution".to_string())
588 .build();
589
590 assert_eq!(container.id(), "api");
591 assert_eq!(container.cluster, Some("compute".to_string()));
592 assert!(!container.stateful);
593 assert!(container.autoscaling.is_some());
594 assert_eq!(container.ports.len(), 1);
595 assert_eq!(container.ports[0].port, 8080);
596 }
597
598 #[test]
599 fn test_stateful_container_with_storage() {
600 let container = Container::new("postgres".to_string())
601 .cluster("compute".to_string())
602 .code(ContainerCode::Image {
603 image: "postgres:16".to_string(),
604 })
605 .cpu(ResourceSpec {
606 min: "1".to_string(),
607 desired: "2".to_string(),
608 })
609 .memory(ResourceSpec {
610 min: "2Gi".to_string(),
611 desired: "4Gi".to_string(),
612 })
613 .port(5432)
614 .stateful(true)
615 .replicas(1)
616 .persistent_storage(PersistentStorage {
617 size: "100Gi".to_string(),
618 mount_path: "/var/lib/postgresql/data".to_string(),
619 })
620 .permissions("database".to_string())
621 .build();
622
623 assert_eq!(container.id(), "postgres");
624 assert!(container.stateful);
625 assert!(container.replicas.is_some());
626 assert!(container.persistent_storage.is_some());
627 }
628
629 #[test]
630 fn test_public_container() {
631 let container = Container::new("frontend".to_string())
632 .cluster("compute".to_string())
633 .code(ContainerCode::Image {
634 image: "frontend:latest".to_string(),
635 })
636 .cpu(ResourceSpec {
637 min: "0.25".to_string(),
638 desired: "0.5".to_string(),
639 })
640 .memory(ResourceSpec {
641 min: "256Mi".to_string(),
642 desired: "512Mi".to_string(),
643 })
644 .port(3000)
645 .public_endpoint(PublicEndpoint {
646 name: "web".to_string(),
647 port: 3000,
648 protocol: ExposeProtocol::Http,
649 host_label: None,
650 wildcard_subdomains: false,
651 })
652 .autoscaling(ContainerAutoscaling {
653 min: 2,
654 desired: 2,
655 max: 20,
656 target_cpu_percent: None,
657 target_memory_percent: None,
658 target_http_in_flight_per_replica: Some(50),
659 max_http_p95_latency_ms: Some(100.0),
660 })
661 .health_check(HealthCheck {
662 path: "/health".to_string(),
663 port: None,
664 method: "GET".to_string(),
665 timeout_seconds: 1,
666 failure_threshold: 3,
667 })
668 .permissions("frontend".to_string())
669 .build();
670
671 assert_eq!(container.ports[0].port, 3000);
672 assert_eq!(container.public_endpoints[0].name, "web");
673 assert!(container.health_check.is_some());
674 }
675
676 #[test]
677 fn test_public_container_endpoint_options() {
678 let container = Container::new("router".to_string())
679 .cluster("compute".to_string())
680 .code(ContainerCode::Image {
681 image: "router:latest".to_string(),
682 })
683 .cpu(ResourceSpec {
684 min: "0.25".to_string(),
685 desired: "0.5".to_string(),
686 })
687 .memory(ResourceSpec {
688 min: "256Mi".to_string(),
689 desired: "512Mi".to_string(),
690 })
691 .public_endpoint(PublicEndpoint {
692 name: "gateway".to_string(),
693 port: 8080,
694 protocol: ExposeProtocol::Http,
695 host_label: Some("gateway".to_string()),
696 wildcard_subdomains: true,
697 })
698 .permissions("router".to_string())
699 .build();
700
701 assert!(container.validate_public_endpoints().is_ok());
702 assert_eq!(container.ports.len(), 1);
703 assert_eq!(container.public_endpoints.len(), 1);
704 assert_eq!(
705 container.public_endpoints[0].host_label.as_deref(),
706 Some("gateway")
707 );
708 assert!(container.public_endpoints[0].wildcard_subdomains);
709 }
710
711 #[test]
712 fn test_public_container_rejects_invalid_host_label() {
713 let container = Container::new("router".to_string())
714 .cluster("compute".to_string())
715 .code(ContainerCode::Image {
716 image: "router:latest".to_string(),
717 })
718 .cpu(ResourceSpec {
719 min: "0.25".to_string(),
720 desired: "0.5".to_string(),
721 })
722 .memory(ResourceSpec {
723 min: "256Mi".to_string(),
724 desired: "512Mi".to_string(),
725 })
726 .public_endpoint(PublicEndpoint {
727 name: "gateway".to_string(),
728 port: 8080,
729 protocol: ExposeProtocol::Http,
730 host_label: Some("bad.label".to_string()),
731 wildcard_subdomains: true,
732 })
733 .permissions("router".to_string())
734 .build();
735
736 assert!(container.validate_public_endpoints().is_err());
737 }
738
739 #[test]
740 fn test_container_with_links() {
741 use crate::Storage;
742
743 let storage = Storage::new("data".to_string()).build();
744
745 let container = Container::new("worker".to_string())
746 .cluster("compute".to_string())
747 .code(ContainerCode::Image {
748 image: "worker:latest".to_string(),
749 })
750 .cpu(ResourceSpec {
751 min: "0.5".to_string(),
752 desired: "1".to_string(),
753 })
754 .memory(ResourceSpec {
755 min: "512Mi".to_string(),
756 desired: "1Gi".to_string(),
757 })
758 .port(8080)
759 .replicas(3)
760 .link(&storage)
761 .permissions("worker".to_string())
762 .build();
763
764 let deps = container.get_dependencies();
766 assert_eq!(deps.len(), 2);
767 }
768
769 #[test]
770 fn test_container_validate_update_immutable_cluster() {
771 let container1 = Container::new("api".to_string())
772 .cluster("cluster-1".to_string())
773 .code(ContainerCode::Image {
774 image: "myapp:v1".to_string(),
775 })
776 .cpu(ResourceSpec {
777 min: "0.5".to_string(),
778 desired: "1".to_string(),
779 })
780 .memory(ResourceSpec {
781 min: "512Mi".to_string(),
782 desired: "1Gi".to_string(),
783 })
784 .port(8080)
785 .replicas(2)
786 .permissions("execution".to_string())
787 .build();
788
789 let container2 = Container::new("api".to_string())
790 .cluster("cluster-2".to_string()) .code(ContainerCode::Image {
792 image: "myapp:v2".to_string(),
793 })
794 .cpu(ResourceSpec {
795 min: "0.5".to_string(),
796 desired: "1".to_string(),
797 })
798 .memory(ResourceSpec {
799 min: "512Mi".to_string(),
800 desired: "1Gi".to_string(),
801 })
802 .port(8080)
803 .replicas(2)
804 .permissions("execution".to_string())
805 .build();
806
807 let result = container1.validate_update(&container2);
808 assert!(result.is_err());
809 }
810
811 #[test]
812 fn test_container_validate_update_allowed_changes() {
813 let container1 = Container::new("api".to_string())
814 .cluster("compute".to_string())
815 .code(ContainerCode::Image {
816 image: "myapp:v1".to_string(),
817 })
818 .cpu(ResourceSpec {
819 min: "0.5".to_string(),
820 desired: "1".to_string(),
821 })
822 .memory(ResourceSpec {
823 min: "512Mi".to_string(),
824 desired: "1Gi".to_string(),
825 })
826 .port(8080)
827 .replicas(2)
828 .permissions("execution".to_string())
829 .build();
830
831 let container2 = Container::new("api".to_string())
832 .cluster("compute".to_string())
833 .code(ContainerCode::Image {
834 image: "myapp:v2".to_string(), })
836 .cpu(ResourceSpec {
837 min: "1".to_string(), desired: "2".to_string(),
839 })
840 .memory(ResourceSpec {
841 min: "1Gi".to_string(),
842 desired: "2Gi".to_string(),
843 })
844 .port(8080)
845 .replicas(5) .permissions("execution".to_string())
847 .build();
848
849 let result = container1.validate_update(&container2);
850 assert!(result.is_ok());
851 }
852
853 #[test]
854 fn test_container_serialization() {
855 let container = Container::new("test".to_string())
856 .cluster("compute".to_string())
857 .code(ContainerCode::Image {
858 image: "test:latest".to_string(),
859 })
860 .cpu(ResourceSpec {
861 min: "0.5".to_string(),
862 desired: "1".to_string(),
863 })
864 .memory(ResourceSpec {
865 min: "512Mi".to_string(),
866 desired: "1Gi".to_string(),
867 })
868 .port(8080)
869 .replicas(1)
870 .permissions("test".to_string())
871 .build();
872
873 let json = serde_json::to_string(&container).unwrap();
874 let deserialized: Container = serde_json::from_str(&json).unwrap();
875 assert_eq!(container, deserialized);
876 }
877
878 #[test]
879 fn test_container_multi_endpoint_validation() {
880 let container = Container::new("multi-tcp".to_string())
881 .cluster("compute".to_string())
882 .code(ContainerCode::Image {
883 image: "test:latest".to_string(),
884 })
885 .cpu(ResourceSpec {
886 min: "1".to_string(),
887 desired: "1".to_string(),
888 })
889 .memory(ResourceSpec {
890 min: "1Gi".to_string(),
891 desired: "1Gi".to_string(),
892 })
893 .port(8080)
894 .public_endpoint(PublicEndpoint {
895 name: "api".to_string(),
896 port: 8080,
897 protocol: ExposeProtocol::Http,
898 host_label: None,
899 wildcard_subdomains: false,
900 })
901 .public_endpoint(PublicEndpoint {
902 name: "wildcard".to_string(),
903 port: 8080,
904 protocol: ExposeProtocol::Http,
905 host_label: Some("wildcard".to_string()),
906 wildcard_subdomains: true,
907 })
908 .replicas(1)
909 .permissions("test".to_string())
910 .build();
911
912 assert!(container.validate_public_endpoints().is_ok());
913
914 let invalid_container = Container::new("multi-http".to_string())
915 .cluster("compute".to_string())
916 .code(ContainerCode::Image {
917 image: "test:latest".to_string(),
918 })
919 .cpu(ResourceSpec {
920 min: "1".to_string(),
921 desired: "1".to_string(),
922 })
923 .memory(ResourceSpec {
924 min: "1Gi".to_string(),
925 desired: "1Gi".to_string(),
926 })
927 .port(8080)
928 .port(9090)
929 .public_endpoint(PublicEndpoint {
930 name: "api".to_string(),
931 port: 8080,
932 protocol: ExposeProtocol::Http,
933 host_label: None,
934 wildcard_subdomains: false,
935 })
936 .public_endpoint(PublicEndpoint {
937 name: "admin".to_string(),
938 port: 9090,
939 protocol: ExposeProtocol::Http,
940 host_label: None,
941 wildcard_subdomains: false,
942 })
943 .replicas(1)
944 .permissions("test".to_string())
945 .build();
946
947 assert!(invalid_container.validate_public_endpoints().is_err());
948 }
949
950 #[test]
951 fn test_container_empty_ports_validation() {
952 let container = Container::new("no-ports".to_string())
953 .cluster("compute".to_string())
954 .code(ContainerCode::Image {
955 image: "test:latest".to_string(),
956 })
957 .cpu(ResourceSpec {
958 min: "1".to_string(),
959 desired: "1".to_string(),
960 })
961 .memory(ResourceSpec {
962 min: "1Gi".to_string(),
963 desired: "1Gi".to_string(),
964 })
965 .replicas(1)
966 .permissions("test".to_string())
967 .build();
968
969 assert!(container.validate_public_endpoints().is_ok());
970 }
971}