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
424#[typetag::serde(name = "container")]
425impl ResourceOutputsDefinition for ContainerOutputs {
426    fn resource_type() -> ResourceType {
427        Container::RESOURCE_TYPE.clone()
428    }
429
430    fn as_any(&self) -> &dyn Any {
431        self
432    }
433
434    fn box_clone(&self) -> Box<dyn ResourceOutputsDefinition> {
435        Box::new(self.clone())
436    }
437
438    fn outputs_eq(&self, other: &dyn ResourceOutputsDefinition) -> bool {
439        other.as_any().downcast_ref::<ContainerOutputs>() == Some(self)
440    }
441}
442
443#[typetag::serde(name = "container")]
444impl ResourceDefinition for Container {
445    fn resource_type() -> ResourceType {
446        Self::RESOURCE_TYPE.clone()
447    }
448
449    fn get_resource_type(&self) -> ResourceType {
450        Self::resource_type()
451    }
452
453    fn id(&self) -> &str {
454        &self.id
455    }
456
457    fn get_dependencies(&self) -> Vec<ResourceRef> {
458        let mut deps = self.links.clone();
459        // Add dependency on the container cluster if explicitly specified.
460        // If None, ContainerClusterMutation will auto-assign at deployment time.
461        if let Some(cluster) = &self.cluster {
462            deps.push(ResourceRef::new(
463                ContainerCluster::RESOURCE_TYPE.clone(),
464                cluster,
465            ));
466        }
467        deps
468    }
469
470    fn get_permissions(&self) -> Option<&str> {
471        Some(&self.permissions)
472    }
473
474    fn validate_update(&self, new_config: &dyn ResourceDefinition) -> Result<()> {
475        let new_container = new_config
476            .as_any()
477            .downcast_ref::<Container>()
478            .ok_or_else(|| {
479                AlienError::new(ErrorData::UnexpectedResourceType {
480                    resource_id: self.id.clone(),
481                    expected: Self::RESOURCE_TYPE,
482                    actual: new_config.get_resource_type(),
483                })
484            })?;
485
486        // Validate the new config's ports
487        new_container.validate_ports()?;
488
489        if self.id != new_container.id {
490            return Err(AlienError::new(ErrorData::InvalidResourceUpdate {
491                resource_id: self.id.clone(),
492                reason: "the 'id' field is immutable".to_string(),
493            }));
494        }
495
496        // Cluster is immutable
497        if self.cluster != new_container.cluster {
498            return Err(AlienError::new(ErrorData::InvalidResourceUpdate {
499                resource_id: self.id.clone(),
500                reason: "the 'cluster' field is immutable".to_string(),
501            }));
502        }
503
504        // Stateful is immutable
505        if self.stateful != new_container.stateful {
506            return Err(AlienError::new(ErrorData::InvalidResourceUpdate {
507                resource_id: self.id.clone(),
508                reason: "the 'stateful' field is immutable".to_string(),
509            }));
510        }
511
512        // Ports are immutable (requires load balancer reconfiguration)
513        if self.ports != new_container.ports {
514            return Err(AlienError::new(ErrorData::InvalidResourceUpdate {
515                resource_id: self.id.clone(),
516                reason: "the 'ports' field is immutable".to_string(),
517            }));
518        }
519
520        // Pool (capacity group) is immutable
521        if self.pool != new_container.pool {
522            return Err(AlienError::new(ErrorData::InvalidResourceUpdate {
523                resource_id: self.id.clone(),
524                reason: "the 'pool' field is immutable".to_string(),
525            }));
526        }
527
528        Ok(())
529    }
530
531    fn as_any(&self) -> &dyn Any {
532        self
533    }
534
535    fn as_any_mut(&mut self) -> &mut dyn Any {
536        self
537    }
538
539    fn box_clone(&self) -> Box<dyn ResourceDefinition> {
540        Box::new(self.clone())
541    }
542
543    fn resource_eq(&self, other: &dyn ResourceDefinition) -> bool {
544        other.as_any().downcast_ref::<Container>() == Some(self)
545    }
546}
547
548#[cfg(test)]
549mod tests {
550    use super::*;
551
552    #[test]
553    fn test_container_creation_with_autoscaling() {
554        let container = Container::new("api".to_string())
555            .cluster("compute".to_string())
556            .code(ContainerCode::Image {
557                image: "myapp:latest".to_string(),
558            })
559            .cpu(ResourceSpec {
560                min: "0.5".to_string(),
561                desired: "1".to_string(),
562            })
563            .memory(ResourceSpec {
564                min: "512Mi".to_string(),
565                desired: "1Gi".to_string(),
566            })
567            .port(8080)
568            .expose_port(8080, ExposeProtocol::Http)
569            .autoscaling(ContainerAutoscaling {
570                min: 2,
571                desired: 3,
572                max: 10,
573                target_cpu_percent: Some(70.0),
574                target_memory_percent: None,
575                target_http_in_flight_per_replica: Some(100),
576                max_http_p95_latency_ms: None,
577            })
578            .permissions("container-execution".to_string())
579            .build();
580
581        assert_eq!(container.id(), "api");
582        assert_eq!(container.cluster, Some("compute".to_string()));
583        assert!(!container.stateful);
584        assert!(container.autoscaling.is_some());
585        assert_eq!(container.ports.len(), 1);
586        assert_eq!(container.ports[0].port, 8080);
587    }
588
589    #[test]
590    fn test_stateful_container_with_storage() {
591        let container = Container::new("postgres".to_string())
592            .cluster("compute".to_string())
593            .code(ContainerCode::Image {
594                image: "postgres:16".to_string(),
595            })
596            .cpu(ResourceSpec {
597                min: "1".to_string(),
598                desired: "2".to_string(),
599            })
600            .memory(ResourceSpec {
601                min: "2Gi".to_string(),
602                desired: "4Gi".to_string(),
603            })
604            .port(5432)
605            .stateful(true)
606            .replicas(1)
607            .persistent_storage(PersistentStorage {
608                size: "100Gi".to_string(),
609                mount_path: "/var/lib/postgresql/data".to_string(),
610                storage_type: Some("gp3".to_string()),
611                iops: Some(3000),
612                throughput: Some(125),
613            })
614            .permissions("database".to_string())
615            .build();
616
617        assert_eq!(container.id(), "postgres");
618        assert!(container.stateful);
619        assert!(container.replicas.is_some());
620        assert!(container.persistent_storage.is_some());
621    }
622
623    #[test]
624    fn test_public_container() {
625        let container = Container::new("frontend".to_string())
626            .cluster("compute".to_string())
627            .code(ContainerCode::Image {
628                image: "frontend:latest".to_string(),
629            })
630            .cpu(ResourceSpec {
631                min: "0.25".to_string(),
632                desired: "0.5".to_string(),
633            })
634            .memory(ResourceSpec {
635                min: "256Mi".to_string(),
636                desired: "512Mi".to_string(),
637            })
638            .port(3000)
639            .expose_port(3000, ExposeProtocol::Http)
640            .autoscaling(ContainerAutoscaling {
641                min: 2,
642                desired: 2,
643                max: 20,
644                target_cpu_percent: None,
645                target_memory_percent: None,
646                target_http_in_flight_per_replica: Some(50),
647                max_http_p95_latency_ms: Some(100.0),
648            })
649            .health_check(HealthCheck {
650                path: "/health".to_string(),
651                port: None,
652                method: "GET".to_string(),
653                timeout_seconds: 1,
654                failure_threshold: 3,
655            })
656            .permissions("frontend".to_string())
657            .build();
658
659        assert_eq!(container.ports[0].port, 3000);
660        assert!(container.ports[0].expose.is_some());
661        assert!(container.health_check.is_some());
662    }
663
664    #[test]
665    fn test_container_with_links() {
666        use crate::Storage;
667
668        let storage = Storage::new("data".to_string()).build();
669
670        let container = Container::new("worker".to_string())
671            .cluster("compute".to_string())
672            .code(ContainerCode::Image {
673                image: "worker:latest".to_string(),
674            })
675            .cpu(ResourceSpec {
676                min: "0.5".to_string(),
677                desired: "1".to_string(),
678            })
679            .memory(ResourceSpec {
680                min: "512Mi".to_string(),
681                desired: "1Gi".to_string(),
682            })
683            .port(8080)
684            .replicas(3)
685            .link(&storage)
686            .permissions("worker".to_string())
687            .build();
688
689        // Should have 2 dependencies: cluster + linked storage
690        let deps = container.get_dependencies();
691        assert_eq!(deps.len(), 2);
692    }
693
694    #[test]
695    fn test_container_validate_update_immutable_cluster() {
696        let container1 = Container::new("api".to_string())
697            .cluster("cluster-1".to_string())
698            .code(ContainerCode::Image {
699                image: "myapp:v1".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 container2 = Container::new("api".to_string())
715            .cluster("cluster-2".to_string()) // Changed cluster
716            .code(ContainerCode::Image {
717                image: "myapp:v2".to_string(),
718            })
719            .cpu(ResourceSpec {
720                min: "0.5".to_string(),
721                desired: "1".to_string(),
722            })
723            .memory(ResourceSpec {
724                min: "512Mi".to_string(),
725                desired: "1Gi".to_string(),
726            })
727            .port(8080)
728            .replicas(2)
729            .permissions("execution".to_string())
730            .build();
731
732        let result = container1.validate_update(&container2);
733        assert!(result.is_err());
734    }
735
736    #[test]
737    fn test_container_validate_update_allowed_changes() {
738        let container1 = Container::new("api".to_string())
739            .cluster("compute".to_string())
740            .code(ContainerCode::Image {
741                image: "myapp:v1".to_string(),
742            })
743            .cpu(ResourceSpec {
744                min: "0.5".to_string(),
745                desired: "1".to_string(),
746            })
747            .memory(ResourceSpec {
748                min: "512Mi".to_string(),
749                desired: "1Gi".to_string(),
750            })
751            .port(8080)
752            .replicas(2)
753            .permissions("execution".to_string())
754            .build();
755
756        let container2 = Container::new("api".to_string())
757            .cluster("compute".to_string())
758            .code(ContainerCode::Image {
759                image: "myapp:v2".to_string(), // Image can change
760            })
761            .cpu(ResourceSpec {
762                min: "1".to_string(), // Resources can change
763                desired: "2".to_string(),
764            })
765            .memory(ResourceSpec {
766                min: "1Gi".to_string(),
767                desired: "2Gi".to_string(),
768            })
769            .port(8080)
770            .replicas(5) // Replicas can change
771            .permissions("execution".to_string())
772            .build();
773
774        let result = container1.validate_update(&container2);
775        assert!(result.is_ok());
776    }
777
778    #[test]
779    fn test_container_serialization() {
780        let container = Container::new("test".to_string())
781            .cluster("compute".to_string())
782            .code(ContainerCode::Image {
783                image: "test:latest".to_string(),
784            })
785            .cpu(ResourceSpec {
786                min: "0.5".to_string(),
787                desired: "1".to_string(),
788            })
789            .memory(ResourceSpec {
790                min: "512Mi".to_string(),
791                desired: "1Gi".to_string(),
792            })
793            .port(8080)
794            .replicas(1)
795            .permissions("test".to_string())
796            .build();
797
798        let json = serde_json::to_string(&container).unwrap();
799        let deserialized: Container = serde_json::from_str(&json).unwrap();
800        assert_eq!(container, deserialized);
801    }
802
803    #[test]
804    fn test_container_multi_port_validation() {
805        // Valid: Multiple TCP ports
806        let container = Container::new("multi-tcp".to_string())
807            .cluster("compute".to_string())
808            .code(ContainerCode::Image {
809                image: "test:latest".to_string(),
810            })
811            .cpu(ResourceSpec {
812                min: "1".to_string(),
813                desired: "1".to_string(),
814            })
815            .memory(ResourceSpec {
816                min: "1Gi".to_string(),
817                desired: "1Gi".to_string(),
818            })
819            .port(8080)
820            .expose_port(8080, ExposeProtocol::Tcp)
821            .port(9090)
822            .expose_port(9090, ExposeProtocol::Tcp)
823            .replicas(1)
824            .permissions("test".to_string())
825            .build();
826
827        assert!(container.validate_ports().is_ok());
828
829        // Invalid: Multiple HTTP ports
830        let invalid_container = Container::new("multi-http".to_string())
831            .cluster("compute".to_string())
832            .code(ContainerCode::Image {
833                image: "test:latest".to_string(),
834            })
835            .cpu(ResourceSpec {
836                min: "1".to_string(),
837                desired: "1".to_string(),
838            })
839            .memory(ResourceSpec {
840                min: "1Gi".to_string(),
841                desired: "1Gi".to_string(),
842            })
843            .port(8080)
844            .expose_port(8080, ExposeProtocol::Http)
845            .port(9090)
846            .expose_port(9090, ExposeProtocol::Http)
847            .replicas(1)
848            .permissions("test".to_string())
849            .build();
850
851        assert!(invalid_container.validate_ports().is_err());
852    }
853
854    #[test]
855    fn test_container_empty_ports_validation() {
856        // Build container with at least one port, then manually clear for testing
857        let mut container = Container::new("no-ports".to_string())
858            .cluster("compute".to_string())
859            .code(ContainerCode::Image {
860                image: "test:latest".to_string(),
861            })
862            .cpu(ResourceSpec {
863                min: "1".to_string(),
864                desired: "1".to_string(),
865            })
866            .memory(ResourceSpec {
867                min: "1Gi".to_string(),
868                desired: "1Gi".to_string(),
869            })
870            .port(8080) // Need at least one port to build
871            .replicas(1)
872            .permissions("test".to_string())
873            .build();
874
875        // Clear ports to test validation
876        container.ports.clear();
877        assert!(container.validate_ports().is_err());
878    }
879}