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