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