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