Skip to main content

alien_core/resources/
container.rs

1//! Container resource for long-running container workloads.
2//!
3//! A Container represents a deployable unit that runs on a ComputeCluster.
4//! It defines the container image, resource requirements, scaling configuration,
5//! and networking settings.
6//!
7//! Containers are orchestrated by the managed container backend, which handles:
8//! - Replica scheduling across machines
9//! - Autoscaling based on CPU, memory, or HTTP metrics
10//! - Health checking and crash recovery
11//! - Service discovery and internal networking
12//! - Load balancer registration for public-facing containers
13
14use crate::error::{ErrorData, Result};
15use crate::resource::{ResourceDefinition, ResourceOutputsDefinition, ResourceRef, ResourceType};
16use crate::resources::{ComputeCluster, ToolchainConfig};
17use crate::LoadBalancerEndpoint;
18use alien_error::AlienError;
19use bon::Builder;
20use serde::{Deserialize, Serialize};
21use std::any::Any;
22use std::collections::HashMap;
23use std::fmt::Debug;
24
25/// Specifies the source of the container's executable code.
26#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
27#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
28#[serde(rename_all = "camelCase", tag = "type")]
29pub enum ContainerCode {
30    /// Container image reference
31    #[serde(rename_all = "camelCase")]
32    Image {
33        /// Container image (e.g., `postgres:16`, `ghcr.io/myorg/myimage:latest`)
34        image: String,
35    },
36    /// Source code to be built
37    #[serde(rename_all = "camelCase")]
38    Source {
39        /// The source directory to build from
40        src: String,
41        /// Toolchain configuration with type-safe options
42        toolchain: ToolchainConfig,
43    },
44}
45
46/// Resource specification with min/desired values.
47#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
48#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
49#[serde(rename_all = "camelCase")]
50pub struct ResourceSpec {
51    /// Minimum resource allocation
52    pub min: String,
53    /// Desired resource allocation (used by scheduler)
54    pub desired: String,
55}
56
57/// GPU specification for a container.
58#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
59#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
60#[serde(rename_all = "camelCase")]
61pub struct ContainerGpuSpec {
62    /// GPU type identifier (e.g., "nvidia-a100", "nvidia-t4")
63    #[serde(rename = "type")]
64    pub gpu_type: String,
65    /// Number of GPUs required (1-8)
66    pub count: u32,
67}
68
69/// Persistent storage configuration for stateful containers.
70#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
71#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
72#[serde(rename_all = "camelCase")]
73pub struct PersistentStorage {
74    /// Storage size (e.g., "100Gi", "500Gi")
75    pub size: String,
76    /// Mount path inside the container
77    pub mount_path: String,
78}
79
80/// Autoscaling configuration for stateless containers.
81#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
82#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
83#[serde(rename_all = "camelCase")]
84pub struct ContainerAutoscaling {
85    /// Minimum replicas (always running)
86    pub min: u32,
87    /// Initial desired replicas at container creation
88    pub desired: u32,
89    /// Maximum replicas under load
90    pub max: u32,
91    /// Target CPU utilization percentage for scaling (default: 70%)
92    #[serde(skip_serializing_if = "Option::is_none")]
93    pub target_cpu_percent: Option<f64>,
94    /// Target memory utilization percentage for scaling (default: 80%)
95    #[serde(skip_serializing_if = "Option::is_none")]
96    pub target_memory_percent: Option<f64>,
97    /// Target in-flight HTTP requests per replica
98    #[serde(skip_serializing_if = "Option::is_none")]
99    pub target_http_in_flight_per_replica: Option<u32>,
100    /// Maximum acceptable p95 HTTP latency in milliseconds
101    #[serde(skip_serializing_if = "Option::is_none")]
102    pub max_http_p95_latency_ms: Option<f64>,
103}
104
105/// HTTP health check configuration.
106#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
107#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
108#[serde(rename_all = "camelCase")]
109pub struct HealthCheck {
110    /// HTTP endpoint path to check (e.g., "/health", "/ready")
111    #[serde(default = "default_health_path")]
112    pub path: String,
113    /// Port to check (defaults to container port if not specified)
114    #[serde(skip_serializing_if = "Option::is_none")]
115    pub port: Option<u16>,
116    /// HTTP method to use for health check
117    #[serde(default = "default_health_method")]
118    pub method: String,
119    /// Request timeout in seconds (1-5)
120    #[serde(default = "default_timeout_seconds")]
121    pub timeout_seconds: u32,
122    /// Number of consecutive failures before marking replica unhealthy
123    #[serde(default = "default_failure_threshold")]
124    pub failure_threshold: u32,
125}
126
127fn default_health_path() -> String {
128    "/health".to_string()
129}
130
131fn default_health_method() -> String {
132    "GET".to_string()
133}
134
135fn default_timeout_seconds() -> u32 {
136    1
137}
138
139fn default_failure_threshold() -> u32 {
140    3
141}
142
143/// Protocol for exposed ports.
144#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
145#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
146#[serde(rename_all = "lowercase")]
147pub enum ExposeProtocol {
148    /// HTTP/HTTPS with TLS termination at load balancer
149    Http,
150    /// TCP passthrough without TLS
151    Tcp,
152}
153
154/// Container port configuration.
155#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
156#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
157#[serde(rename_all = "camelCase")]
158pub struct ContainerPort {
159    /// Port number
160    pub port: u16,
161    /// Optional exposure protocol (if None, port is internal-only)
162    #[serde(skip_serializing_if = "Option::is_none")]
163    pub expose: Option<ExposeProtocol>,
164}
165
166/// Container resource for running long-running container workloads.
167///
168/// A Container defines a deployable unit that runs on a ComputeCluster.
169/// The managed container backend handles scheduling replicas across machines,
170/// autoscaling based on various metrics, and service discovery.
171///
172/// ## Example
173///
174/// ```rust
175/// use alien_core::{Container, ContainerCode, ResourceSpec, ContainerAutoscaling, ContainerPort, ExposeProtocol};
176///
177/// let container = Container::new("api".to_string())
178///     .cluster("compute".to_string())
179///     .code(ContainerCode::Image {
180///         image: "myapp:latest".to_string(),
181///     })
182///     .cpu(ResourceSpec { min: "0.5".to_string(), desired: "1".to_string() })
183///     .memory(ResourceSpec { min: "512Mi".to_string(), desired: "1Gi".to_string() })
184///     .port(8080)
185///     .expose_port(8080, ExposeProtocol::Http)
186///     .autoscaling(ContainerAutoscaling {
187///         min: 2,
188///         desired: 3,
189///         max: 10,
190///         target_cpu_percent: Some(70.0),
191///         target_memory_percent: None,
192///         target_http_in_flight_per_replica: Some(100),
193///         max_http_p95_latency_ms: None,
194///     })
195///     .permissions("container-execution".to_string())
196///     .build();
197/// ```
198#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Builder)]
199#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
200#[serde(rename_all = "camelCase", deny_unknown_fields)]
201#[builder(start_fn = new)]
202pub struct Container {
203    /// Unique identifier for the container.
204    /// Must be DNS-compatible: lowercase alphanumeric with hyphens.
205    #[builder(start_fn)]
206    pub id: String,
207
208    /// Resource links (dependencies)
209    #[builder(field)]
210    pub links: Vec<ResourceRef>,
211
212    /// Container ports to expose (at least one required)
213    #[builder(field)]
214    pub ports: Vec<ContainerPort>,
215
216    /// ComputeCluster resource ID that this container runs on.
217    /// If None, will be auto-assigned by ComputeClusterMutation at deployment time.
218    #[serde(skip_serializing_if = "Option::is_none")]
219    pub cluster: Option<String>,
220
221    /// Container code (image or source)
222    pub code: ContainerCode,
223
224    /// CPU resource requirements
225    pub cpu: ResourceSpec,
226
227    /// Memory resource requirements (must use Ki/Mi/Gi/Ti suffix)
228    pub memory: ResourceSpec,
229
230    /// GPU requirements (optional)
231    #[serde(skip_serializing_if = "Option::is_none")]
232    pub gpu: Option<ContainerGpuSpec>,
233
234    /// Ephemeral storage requirement (e.g., "10Gi")
235    #[serde(skip_serializing_if = "Option::is_none")]
236    pub ephemeral_storage: Option<String>,
237
238    /// Persistent storage configuration (only for stateful containers)
239    #[serde(skip_serializing_if = "Option::is_none")]
240    pub persistent_storage: Option<PersistentStorage>,
241
242    /// Fixed replica count (for stateful containers or stateless without autoscaling)
243    #[serde(skip_serializing_if = "Option::is_none")]
244    pub replicas: Option<u32>,
245
246    /// Autoscaling configuration (only for stateless containers)
247    #[serde(skip_serializing_if = "Option::is_none")]
248    pub autoscaling: Option<ContainerAutoscaling>,
249
250    /// Whether container is stateful (gets stable ordinals, optional persistent volumes)
251    #[builder(default = false)]
252    #[serde(default)]
253    pub stateful: bool,
254
255    /// Environment variables
256    #[builder(default)]
257    #[serde(default)]
258    pub environment: HashMap<String, String>,
259
260    /// Capacity group to run on (must exist in the cluster)
261    /// If not specified, containers are scheduled to any available group.
262    #[serde(skip_serializing_if = "Option::is_none")]
263    pub pool: Option<String>,
264
265    /// Permission profile name
266    pub permissions: String,
267
268    /// Health check configuration
269    #[serde(skip_serializing_if = "Option::is_none")]
270    pub health_check: Option<HealthCheck>,
271
272    /// Command to override image default
273    #[serde(skip_serializing_if = "Option::is_none")]
274    pub command: Option<Vec<String>>,
275}
276
277impl Container {
278    /// The resource type identifier for Container
279    pub const RESOURCE_TYPE: ResourceType = ResourceType::from_static("container");
280
281    /// Returns the container's unique identifier.
282    pub fn id(&self) -> &str {
283        &self.id
284    }
285
286    /// Returns the permission profile name for this container.
287    pub fn get_permissions(&self) -> &str {
288        &self.permissions
289    }
290
291    /// Returns true if this container is stateless (not stateful).
292    pub fn is_stateless(&self) -> bool {
293        !self.stateful
294    }
295
296    /// Validates the ports configuration.
297    fn validate_ports(&self) -> Result<()> {
298        // At most one HTTP port is allowed
299        let http_ports: Vec<_> = self
300            .ports
301            .iter()
302            .filter(|p| p.expose == Some(ExposeProtocol::Http))
303            .collect();
304
305        if http_ports.len() > 1 {
306            return Err(AlienError::new(ErrorData::InvalidResourceUpdate {
307                resource_id: self.id.clone(),
308                reason: "at most one port can be exposed with HTTP protocol (multiple TCP ports are allowed)".to_string(),
309            }));
310        }
311
312        Ok(())
313    }
314}
315
316impl<S: container_builder::State> ContainerBuilder<S> {
317    /// Links the container to another resource with specified permissions.
318    pub fn link<R: ?Sized>(mut self, resource: &R) -> Self
319    where
320        for<'a> &'a R: Into<ResourceRef>,
321    {
322        let resource_ref: ResourceRef = resource.into();
323        self.links.push(resource_ref);
324        self
325    }
326
327    /// Adds an internal-only port to the container.
328    pub fn port(mut self, port: u16) -> Self {
329        self.ports.push(ContainerPort { port, expose: None });
330        self
331    }
332
333    /// Exposes a specific port publicly via load balancer.
334    pub fn expose_port(mut self, port: u16, protocol: ExposeProtocol) -> Self {
335        // Find existing port or add new one
336        if let Some(existing) = self.ports.iter_mut().find(|p| p.port == port) {
337            existing.expose = Some(protocol);
338        } else {
339            self.ports.push(ContainerPort {
340                port,
341                expose: Some(protocol),
342            });
343        }
344        self
345    }
346}
347
348/// Container status in the managed container backend.
349#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
350#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
351#[serde(rename_all = "camelCase")]
352pub enum ContainerStatus {
353    /// Waiting for replicas to start
354    Pending,
355    /// Min replicas healthy and serving
356    Running,
357    /// Manually stopped
358    Stopped,
359    /// Something is wrong — see statusReason/statusMessage; scheduler keeps retrying.
360    /// Covers all failure modes: crash-looping, unschedulable, replica failures, etc.
361    Failing,
362}
363
364/// Status of a single container replica.
365#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
366#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
367#[serde(rename_all = "camelCase")]
368pub struct ReplicaStatus {
369    /// Replica ID (e.g., "api-0", "api-1")
370    pub replica_id: String,
371    /// Ordinal (for stateful containers)
372    pub ordinal: Option<u32>,
373    /// Machine ID the replica is running on
374    pub machine_id: Option<String>,
375    /// Whether the replica is healthy
376    pub healthy: bool,
377    /// Container IP address (for service discovery)
378    pub container_ip: Option<String>,
379}
380
381/// Outputs generated by a successfully provisioned Container.
382#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
383#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
384#[serde(rename_all = "camelCase")]
385pub struct ContainerOutputs {
386    /// Container name in the managed container backend
387    pub name: String,
388    /// Current container status
389    pub status: ContainerStatus,
390    /// Number of current replicas
391    pub current_replicas: u32,
392    /// Desired number of replicas
393    pub desired_replicas: u32,
394    /// Internal DNS name (e.g., "api.svc")
395    pub internal_dns: String,
396    /// Public URL (if exposed publicly)
397    #[serde(skip_serializing_if = "Option::is_none")]
398    pub url: Option<String>,
399    /// Status of each replica
400    pub replicas: Vec<ReplicaStatus>,
401    /// Load balancer endpoint information for DNS management (optional).
402    /// Used by the DNS controller to create custom domain mappings.
403    #[serde(skip_serializing_if = "Option::is_none")]
404    pub load_balancer_endpoint: Option<LoadBalancerEndpoint>,
405}
406
407impl ResourceOutputsDefinition for ContainerOutputs {
408    fn get_resource_type(&self) -> ResourceType {
409        Container::RESOURCE_TYPE.clone()
410    }
411
412    fn as_any(&self) -> &dyn Any {
413        self
414    }
415
416    fn box_clone(&self) -> Box<dyn ResourceOutputsDefinition> {
417        Box::new(self.clone())
418    }
419
420    fn outputs_eq(&self, other: &dyn ResourceOutputsDefinition) -> bool {
421        other.as_any().downcast_ref::<ContainerOutputs>() == Some(self)
422    }
423
424    fn to_json_value(&self) -> serde_json::Result<serde_json::Value> {
425        serde_json::to_value(self)
426    }
427}
428
429impl ResourceDefinition for Container {
430    fn get_resource_type(&self) -> ResourceType {
431        Self::RESOURCE_TYPE
432    }
433
434    fn id(&self) -> &str {
435        &self.id
436    }
437
438    fn get_dependencies(&self) -> Vec<ResourceRef> {
439        let mut deps = self.links.clone();
440        // Add dependency on the container cluster if explicitly specified.
441        // If None, ComputeClusterMutation will auto-assign at deployment time.
442        if let Some(cluster) = &self.cluster {
443            deps.push(ResourceRef::new(
444                ComputeCluster::RESOURCE_TYPE.clone(),
445                cluster,
446            ));
447        }
448        deps
449    }
450
451    fn get_permissions(&self) -> Option<&str> {
452        Some(&self.permissions)
453    }
454
455    fn validate_update(&self, new_config: &dyn ResourceDefinition) -> Result<()> {
456        let new_container = new_config
457            .as_any()
458            .downcast_ref::<Container>()
459            .ok_or_else(|| {
460                AlienError::new(ErrorData::UnexpectedResourceType {
461                    resource_id: self.id.clone(),
462                    expected: Self::RESOURCE_TYPE,
463                    actual: new_config.get_resource_type(),
464                })
465            })?;
466
467        // Validate the new config's ports
468        new_container.validate_ports()?;
469
470        if self.id != new_container.id {
471            return Err(AlienError::new(ErrorData::InvalidResourceUpdate {
472                resource_id: self.id.clone(),
473                reason: "the 'id' field is immutable".to_string(),
474            }));
475        }
476
477        // Cluster is immutable
478        if self.cluster != new_container.cluster {
479            return Err(AlienError::new(ErrorData::InvalidResourceUpdate {
480                resource_id: self.id.clone(),
481                reason: "the 'cluster' field is immutable".to_string(),
482            }));
483        }
484
485        // Stateful is immutable
486        if self.stateful != new_container.stateful {
487            return Err(AlienError::new(ErrorData::InvalidResourceUpdate {
488                resource_id: self.id.clone(),
489                reason: "the 'stateful' field is immutable".to_string(),
490            }));
491        }
492
493        // Ports are immutable (requires load balancer reconfiguration)
494        if self.ports != new_container.ports {
495            return Err(AlienError::new(ErrorData::InvalidResourceUpdate {
496                resource_id: self.id.clone(),
497                reason: "the 'ports' field is immutable".to_string(),
498            }));
499        }
500
501        // Pool (capacity group) is immutable
502        if self.pool != new_container.pool {
503            return Err(AlienError::new(ErrorData::InvalidResourceUpdate {
504                resource_id: self.id.clone(),
505                reason: "the 'pool' field is immutable".to_string(),
506            }));
507        }
508
509        Ok(())
510    }
511
512    fn as_any(&self) -> &dyn Any {
513        self
514    }
515
516    fn as_any_mut(&mut self) -> &mut dyn Any {
517        self
518    }
519
520    fn box_clone(&self) -> Box<dyn ResourceDefinition> {
521        Box::new(self.clone())
522    }
523
524    fn resource_eq(&self, other: &dyn ResourceDefinition) -> bool {
525        other.as_any().downcast_ref::<Container>() == Some(self)
526    }
527
528    fn to_json_value(&self) -> serde_json::Result<serde_json::Value> {
529        serde_json::to_value(self)
530    }
531}
532
533#[cfg(test)]
534mod tests {
535    use super::*;
536
537    #[test]
538    fn test_container_creation_with_autoscaling() {
539        let container = Container::new("api".to_string())
540            .cluster("compute".to_string())
541            .code(ContainerCode::Image {
542                image: "myapp:latest".to_string(),
543            })
544            .cpu(ResourceSpec {
545                min: "0.5".to_string(),
546                desired: "1".to_string(),
547            })
548            .memory(ResourceSpec {
549                min: "512Mi".to_string(),
550                desired: "1Gi".to_string(),
551            })
552            .port(8080)
553            .expose_port(8080, ExposeProtocol::Http)
554            .autoscaling(ContainerAutoscaling {
555                min: 2,
556                desired: 3,
557                max: 10,
558                target_cpu_percent: Some(70.0),
559                target_memory_percent: None,
560                target_http_in_flight_per_replica: Some(100),
561                max_http_p95_latency_ms: None,
562            })
563            .permissions("container-execution".to_string())
564            .build();
565
566        assert_eq!(container.id(), "api");
567        assert_eq!(container.cluster, Some("compute".to_string()));
568        assert!(!container.stateful);
569        assert!(container.autoscaling.is_some());
570        assert_eq!(container.ports.len(), 1);
571        assert_eq!(container.ports[0].port, 8080);
572    }
573
574    #[test]
575    fn test_stateful_container_with_storage() {
576        let container = Container::new("postgres".to_string())
577            .cluster("compute".to_string())
578            .code(ContainerCode::Image {
579                image: "postgres:16".to_string(),
580            })
581            .cpu(ResourceSpec {
582                min: "1".to_string(),
583                desired: "2".to_string(),
584            })
585            .memory(ResourceSpec {
586                min: "2Gi".to_string(),
587                desired: "4Gi".to_string(),
588            })
589            .port(5432)
590            .stateful(true)
591            .replicas(1)
592            .persistent_storage(PersistentStorage {
593                size: "100Gi".to_string(),
594                mount_path: "/var/lib/postgresql/data".to_string(),
595            })
596            .permissions("database".to_string())
597            .build();
598
599        assert_eq!(container.id(), "postgres");
600        assert!(container.stateful);
601        assert!(container.replicas.is_some());
602        assert!(container.persistent_storage.is_some());
603    }
604
605    #[test]
606    fn test_public_container() {
607        let container = Container::new("frontend".to_string())
608            .cluster("compute".to_string())
609            .code(ContainerCode::Image {
610                image: "frontend:latest".to_string(),
611            })
612            .cpu(ResourceSpec {
613                min: "0.25".to_string(),
614                desired: "0.5".to_string(),
615            })
616            .memory(ResourceSpec {
617                min: "256Mi".to_string(),
618                desired: "512Mi".to_string(),
619            })
620            .port(3000)
621            .expose_port(3000, ExposeProtocol::Http)
622            .autoscaling(ContainerAutoscaling {
623                min: 2,
624                desired: 2,
625                max: 20,
626                target_cpu_percent: None,
627                target_memory_percent: None,
628                target_http_in_flight_per_replica: Some(50),
629                max_http_p95_latency_ms: Some(100.0),
630            })
631            .health_check(HealthCheck {
632                path: "/health".to_string(),
633                port: None,
634                method: "GET".to_string(),
635                timeout_seconds: 1,
636                failure_threshold: 3,
637            })
638            .permissions("frontend".to_string())
639            .build();
640
641        assert_eq!(container.ports[0].port, 3000);
642        assert!(container.ports[0].expose.is_some());
643        assert!(container.health_check.is_some());
644    }
645
646    #[test]
647    fn test_container_with_links() {
648        use crate::Storage;
649
650        let storage = Storage::new("data".to_string()).build();
651
652        let container = Container::new("worker".to_string())
653            .cluster("compute".to_string())
654            .code(ContainerCode::Image {
655                image: "worker:latest".to_string(),
656            })
657            .cpu(ResourceSpec {
658                min: "0.5".to_string(),
659                desired: "1".to_string(),
660            })
661            .memory(ResourceSpec {
662                min: "512Mi".to_string(),
663                desired: "1Gi".to_string(),
664            })
665            .port(8080)
666            .replicas(3)
667            .link(&storage)
668            .permissions("worker".to_string())
669            .build();
670
671        // Should have 2 dependencies: cluster + linked storage
672        let deps = container.get_dependencies();
673        assert_eq!(deps.len(), 2);
674    }
675
676    #[test]
677    fn test_container_validate_update_immutable_cluster() {
678        let container1 = Container::new("api".to_string())
679            .cluster("cluster-1".to_string())
680            .code(ContainerCode::Image {
681                image: "myapp:v1".to_string(),
682            })
683            .cpu(ResourceSpec {
684                min: "0.5".to_string(),
685                desired: "1".to_string(),
686            })
687            .memory(ResourceSpec {
688                min: "512Mi".to_string(),
689                desired: "1Gi".to_string(),
690            })
691            .port(8080)
692            .replicas(2)
693            .permissions("execution".to_string())
694            .build();
695
696        let container2 = Container::new("api".to_string())
697            .cluster("cluster-2".to_string()) // Changed cluster
698            .code(ContainerCode::Image {
699                image: "myapp:v2".to_string(),
700            })
701            .cpu(ResourceSpec {
702                min: "0.5".to_string(),
703                desired: "1".to_string(),
704            })
705            .memory(ResourceSpec {
706                min: "512Mi".to_string(),
707                desired: "1Gi".to_string(),
708            })
709            .port(8080)
710            .replicas(2)
711            .permissions("execution".to_string())
712            .build();
713
714        let result = container1.validate_update(&container2);
715        assert!(result.is_err());
716    }
717
718    #[test]
719    fn test_container_validate_update_allowed_changes() {
720        let container1 = Container::new("api".to_string())
721            .cluster("compute".to_string())
722            .code(ContainerCode::Image {
723                image: "myapp:v1".to_string(),
724            })
725            .cpu(ResourceSpec {
726                min: "0.5".to_string(),
727                desired: "1".to_string(),
728            })
729            .memory(ResourceSpec {
730                min: "512Mi".to_string(),
731                desired: "1Gi".to_string(),
732            })
733            .port(8080)
734            .replicas(2)
735            .permissions("execution".to_string())
736            .build();
737
738        let container2 = Container::new("api".to_string())
739            .cluster("compute".to_string())
740            .code(ContainerCode::Image {
741                image: "myapp:v2".to_string(), // Image can change
742            })
743            .cpu(ResourceSpec {
744                min: "1".to_string(), // Resources can change
745                desired: "2".to_string(),
746            })
747            .memory(ResourceSpec {
748                min: "1Gi".to_string(),
749                desired: "2Gi".to_string(),
750            })
751            .port(8080)
752            .replicas(5) // Replicas can change
753            .permissions("execution".to_string())
754            .build();
755
756        let result = container1.validate_update(&container2);
757        assert!(result.is_ok());
758    }
759
760    #[test]
761    fn test_container_serialization() {
762        let container = Container::new("test".to_string())
763            .cluster("compute".to_string())
764            .code(ContainerCode::Image {
765                image: "test:latest".to_string(),
766            })
767            .cpu(ResourceSpec {
768                min: "0.5".to_string(),
769                desired: "1".to_string(),
770            })
771            .memory(ResourceSpec {
772                min: "512Mi".to_string(),
773                desired: "1Gi".to_string(),
774            })
775            .port(8080)
776            .replicas(1)
777            .permissions("test".to_string())
778            .build();
779
780        let json = serde_json::to_string(&container).unwrap();
781        let deserialized: Container = serde_json::from_str(&json).unwrap();
782        assert_eq!(container, deserialized);
783    }
784
785    #[test]
786    fn test_container_multi_port_validation() {
787        // Valid: Multiple TCP ports
788        let container = Container::new("multi-tcp".to_string())
789            .cluster("compute".to_string())
790            .code(ContainerCode::Image {
791                image: "test:latest".to_string(),
792            })
793            .cpu(ResourceSpec {
794                min: "1".to_string(),
795                desired: "1".to_string(),
796            })
797            .memory(ResourceSpec {
798                min: "1Gi".to_string(),
799                desired: "1Gi".to_string(),
800            })
801            .port(8080)
802            .expose_port(8080, ExposeProtocol::Tcp)
803            .port(9090)
804            .expose_port(9090, ExposeProtocol::Tcp)
805            .replicas(1)
806            .permissions("test".to_string())
807            .build();
808
809        assert!(container.validate_ports().is_ok());
810
811        // Invalid: Multiple HTTP ports
812        let invalid_container = Container::new("multi-http".to_string())
813            .cluster("compute".to_string())
814            .code(ContainerCode::Image {
815                image: "test:latest".to_string(),
816            })
817            .cpu(ResourceSpec {
818                min: "1".to_string(),
819                desired: "1".to_string(),
820            })
821            .memory(ResourceSpec {
822                min: "1Gi".to_string(),
823                desired: "1Gi".to_string(),
824            })
825            .port(8080)
826            .expose_port(8080, ExposeProtocol::Http)
827            .port(9090)
828            .expose_port(9090, ExposeProtocol::Http)
829            .replicas(1)
830            .permissions("test".to_string())
831            .build();
832
833        assert!(invalid_container.validate_ports().is_err());
834    }
835
836    #[test]
837    fn test_container_empty_ports_validation() {
838        let container = Container::new("no-ports".to_string())
839            .cluster("compute".to_string())
840            .code(ContainerCode::Image {
841                image: "test:latest".to_string(),
842            })
843            .cpu(ResourceSpec {
844                min: "1".to_string(),
845                desired: "1".to_string(),
846            })
847            .memory(ResourceSpec {
848                min: "1Gi".to_string(),
849                desired: "1Gi".to_string(),
850            })
851            .replicas(1)
852            .permissions("test".to_string())
853            .build();
854
855        assert!(container.validate_ports().is_ok());
856    }
857}