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    /// Grace period in seconds for stopping replicas during updates, drains, and deletes.
275    ///
276    /// When omitted, the runtime backend applies its default. Valid values are
277    /// 1 second through 24 hours.
278    #[serde(skip_serializing_if = "Option::is_none")]
279    #[cfg_attr(feature = "openapi", schema(minimum = 1, maximum = 86400))]
280    pub stop_grace_period_seconds: Option<u32>,
281}
282
283impl Container {
284    /// The resource type identifier for Container
285    pub const RESOURCE_TYPE: ResourceType = ResourceType::from_static("container");
286
287    /// Returns the container's unique identifier.
288    pub fn id(&self) -> &str {
289        &self.id
290    }
291
292    /// Returns the permission profile name for this container.
293    pub fn get_permissions(&self) -> &str {
294        &self.permissions
295    }
296
297    /// Returns true if this container is stateless (not stateful).
298    pub fn is_stateless(&self) -> bool {
299        !self.stateful
300    }
301
302    /// Validates the public endpoint configuration.
303    fn validate_public_endpoints(&self) -> Result<()> {
304        let mut endpoint_names = std::collections::HashSet::new();
305        let mut backend_ports = std::collections::HashSet::new();
306        let mut apex_endpoint_name: Option<&str> = None;
307
308        for endpoint in &self.public_endpoints {
309            endpoint.validate_for_resource(&self.id)?;
310
311            if !endpoint_names.insert(endpoint.name.as_str()) {
312                return Err(AlienError::new(ErrorData::InvalidResourceUpdate {
313                    resource_id: self.id.clone(),
314                    reason: format!("duplicate public endpoint name '{}'", endpoint.name),
315                }));
316            }
317
318            if endpoint.host_label.as_deref() == Some(APEX_HOST_LABEL) {
319                if let Some(existing_name) = apex_endpoint_name {
320                    return Err(AlienError::new(ErrorData::InvalidResourceUpdate {
321                        resource_id: self.id.clone(),
322                        reason: format!(
323                            "only one apex public endpoint is allowed per resource; '{}' already uses hostLabel '@'",
324                            existing_name
325                        ),
326                    }));
327                }
328                apex_endpoint_name = Some(endpoint.name.as_str());
329            }
330
331            backend_ports.insert(endpoint.port);
332
333            if !self.ports.iter().any(|port| port.port == endpoint.port) {
334                return Err(AlienError::new(ErrorData::InvalidResourceUpdate {
335                    resource_id: self.id.clone(),
336                    reason: format!(
337                        "public endpoint '{}' references undeclared port {}",
338                        endpoint.name, endpoint.port
339                    ),
340                }));
341            }
342        }
343
344        if backend_ports.len() > 1 {
345            return Err(AlienError::new(ErrorData::InvalidResourceUpdate {
346                resource_id: self.id.clone(),
347                reason:
348                    "public endpoints on one container must currently route to the same backend port"
349                        .to_string(),
350            }));
351        }
352
353        Ok(())
354    }
355}
356
357impl<S: container_builder::State> ContainerBuilder<S> {
358    /// Links the container to another resource with specified permissions.
359    pub fn link<R: ?Sized>(mut self, resource: &R) -> Self
360    where
361        for<'a> &'a R: Into<ResourceRef>,
362    {
363        let resource_ref: ResourceRef = resource.into();
364        self.links.push(resource_ref);
365        self
366    }
367
368    /// Adds an internal-only port to the container.
369    pub fn port(mut self, port: u16) -> Self {
370        self.ports.push(ContainerPort { port });
371        self
372    }
373
374    /// Exposes a named public endpoint.
375    pub fn public_endpoint(mut self, endpoint: PublicEndpoint) -> Self {
376        if !self.ports.iter().any(|p| p.port == endpoint.port) {
377            self.ports.push(ContainerPort {
378                port: endpoint.port,
379            });
380        }
381        self.public_endpoints.push(endpoint);
382        self
383    }
384}
385
386/// Container status in the managed container backend.
387#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
388#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
389#[serde(rename_all = "camelCase")]
390pub enum ContainerStatus {
391    /// Waiting for replicas to start
392    Pending,
393    /// Min replicas healthy and serving
394    Running,
395    /// Manually stopped
396    Stopped,
397    /// Something is wrong — see statusReason/statusMessage; scheduler keeps retrying.
398    /// Covers all failure modes: crash-looping, unschedulable, replica failures, etc.
399    Failing,
400}
401
402/// Status of a single container replica.
403#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
404#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
405#[serde(rename_all = "camelCase")]
406pub struct ReplicaStatus {
407    /// Replica ID (e.g., "api-0", "api-1")
408    pub replica_id: String,
409    /// Ordinal (for stateful containers)
410    pub ordinal: Option<u32>,
411    /// Machine ID the replica is running on
412    pub machine_id: Option<String>,
413    /// Whether the replica is healthy
414    pub healthy: bool,
415    /// Container IP address (for service discovery)
416    pub container_ip: Option<String>,
417}
418
419/// Outputs generated by a successfully provisioned Container.
420#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
421#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
422#[serde(rename_all = "camelCase")]
423pub struct ContainerOutputs {
424    /// Container name in the managed container backend
425    pub name: String,
426    /// Current container status
427    pub status: ContainerStatus,
428    /// Number of current replicas
429    pub current_replicas: u32,
430    /// Desired number of replicas
431    pub desired_replicas: u32,
432    /// Internal DNS name (e.g., "api.svc")
433    pub internal_dns: String,
434    /// Public endpoints resolved for this container.
435    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
436    pub public_endpoints: HashMap<String, PublicEndpointOutput>,
437    /// Status of each replica
438    pub replicas: Vec<ReplicaStatus>,
439}
440
441impl ResourceOutputsDefinition for ContainerOutputs {
442    fn get_resource_type(&self) -> ResourceType {
443        Container::RESOURCE_TYPE.clone()
444    }
445
446    fn as_any(&self) -> &dyn Any {
447        self
448    }
449
450    fn box_clone(&self) -> Box<dyn ResourceOutputsDefinition> {
451        Box::new(self.clone())
452    }
453
454    fn outputs_eq(&self, other: &dyn ResourceOutputsDefinition) -> bool {
455        other.as_any().downcast_ref::<ContainerOutputs>() == Some(self)
456    }
457
458    fn to_json_value(&self) -> serde_json::Result<serde_json::Value> {
459        serde_json::to_value(self)
460    }
461}
462
463impl ResourceDefinition for Container {
464    fn get_resource_type(&self) -> ResourceType {
465        Self::RESOURCE_TYPE
466    }
467
468    fn id(&self) -> &str {
469        &self.id
470    }
471
472    fn get_dependencies(&self) -> Vec<ResourceRef> {
473        let mut deps = self.links.clone();
474        // Add dependency on the container cluster if explicitly specified.
475        // If None, ComputeClusterMutation will auto-assign at deployment time.
476        if let Some(cluster) = &self.cluster {
477            deps.push(ResourceRef::new(
478                ComputeCluster::RESOURCE_TYPE.clone(),
479                cluster,
480            ));
481        }
482        deps
483    }
484
485    fn get_permissions(&self) -> Option<&str> {
486        Some(&self.permissions)
487    }
488
489    fn validate_update(&self, new_config: &dyn ResourceDefinition) -> Result<()> {
490        let new_container = new_config
491            .as_any()
492            .downcast_ref::<Container>()
493            .ok_or_else(|| {
494                AlienError::new(ErrorData::UnexpectedResourceType {
495                    resource_id: self.id.clone(),
496                    expected: Self::RESOURCE_TYPE,
497                    actual: new_config.get_resource_type(),
498                })
499            })?;
500
501        // Validate the new config's public endpoints.
502        new_container.validate_public_endpoints()?;
503
504        if self.id != new_container.id {
505            return Err(AlienError::new(ErrorData::InvalidResourceUpdate {
506                resource_id: self.id.clone(),
507                reason: "the 'id' field is immutable".to_string(),
508            }));
509        }
510
511        // Cluster is immutable
512        if self.cluster != new_container.cluster {
513            return Err(AlienError::new(ErrorData::InvalidResourceUpdate {
514                resource_id: self.id.clone(),
515                reason: "the 'cluster' field is immutable".to_string(),
516            }));
517        }
518
519        // Stateful is immutable
520        if self.stateful != new_container.stateful {
521            return Err(AlienError::new(ErrorData::InvalidResourceUpdate {
522                resource_id: self.id.clone(),
523                reason: "the 'stateful' field is immutable".to_string(),
524            }));
525        }
526
527        // Ports are immutable (requires load balancer reconfiguration)
528        if self.ports != new_container.ports {
529            return Err(AlienError::new(ErrorData::InvalidResourceUpdate {
530                resource_id: self.id.clone(),
531                reason: "the 'ports' field is immutable".to_string(),
532            }));
533        }
534
535        if self.public_endpoints != new_container.public_endpoints {
536            return Err(AlienError::new(ErrorData::InvalidResourceUpdate {
537                resource_id: self.id.clone(),
538                reason: "the 'publicEndpoints' field is immutable".to_string(),
539            }));
540        }
541
542        // Pool (capacity group) is immutable
543        if self.pool != new_container.pool {
544            return Err(AlienError::new(ErrorData::InvalidResourceUpdate {
545                resource_id: self.id.clone(),
546                reason: "the 'pool' field is immutable".to_string(),
547            }));
548        }
549
550        Ok(())
551    }
552
553    fn as_any(&self) -> &dyn Any {
554        self
555    }
556
557    fn as_any_mut(&mut self) -> &mut dyn Any {
558        self
559    }
560
561    fn box_clone(&self) -> Box<dyn ResourceDefinition> {
562        Box::new(self.clone())
563    }
564
565    fn resource_eq(&self, other: &dyn ResourceDefinition) -> bool {
566        other.as_any().downcast_ref::<Container>() == Some(self)
567    }
568
569    fn to_json_value(&self) -> serde_json::Result<serde_json::Value> {
570        serde_json::to_value(self)
571    }
572}
573
574#[cfg(test)]
575mod tests {
576    use super::*;
577    use crate::resources::ExposeProtocol;
578
579    #[test]
580    fn test_container_creation_with_autoscaling() {
581        let container = Container::new("api".to_string())
582            .cluster("compute".to_string())
583            .code(ContainerCode::Image {
584                image: "myapp:latest".to_string(),
585            })
586            .cpu(ResourceSpec {
587                min: "0.5".to_string(),
588                desired: "1".to_string(),
589            })
590            .memory(ResourceSpec {
591                min: "512Mi".to_string(),
592                desired: "1Gi".to_string(),
593            })
594            .port(8080)
595            .public_endpoint(PublicEndpoint {
596                name: "api".to_string(),
597                port: 8080,
598                protocol: ExposeProtocol::Http,
599                host_label: None,
600                wildcard_subdomains: false,
601            })
602            .autoscaling(ContainerAutoscaling {
603                min: 2,
604                desired: 3,
605                max: 10,
606                target_cpu_percent: Some(70.0),
607                target_memory_percent: None,
608                target_http_in_flight_per_replica: Some(100),
609                max_http_p95_latency_ms: None,
610            })
611            .permissions("container-execution".to_string())
612            .build();
613
614        assert_eq!(container.id(), "api");
615        assert_eq!(container.cluster, Some("compute".to_string()));
616        assert!(!container.stateful);
617        assert!(container.autoscaling.is_some());
618        assert_eq!(container.ports.len(), 1);
619        assert_eq!(container.ports[0].port, 8080);
620    }
621
622    #[test]
623    fn container_serializes_stop_grace_period_when_set() {
624        let container = Container::new("api".to_string())
625            .cluster("compute".to_string())
626            .code(ContainerCode::Image {
627                image: "myapp:latest".to_string(),
628            })
629            .cpu(ResourceSpec {
630                min: "0.5".to_string(),
631                desired: "1".to_string(),
632            })
633            .memory(ResourceSpec {
634                min: "512Mi".to_string(),
635                desired: "1Gi".to_string(),
636            })
637            .port(8080)
638            .permissions("container-execution".to_string())
639            .stop_grace_period_seconds(21_600)
640            .build();
641
642        let json = serde_json::to_value(&container).expect("container should serialize");
643        assert_eq!(json["stopGracePeriodSeconds"], 21_600);
644    }
645
646    #[test]
647    fn container_omits_stop_grace_period_when_absent() {
648        let container = Container::new("api".to_string())
649            .cluster("compute".to_string())
650            .code(ContainerCode::Image {
651                image: "myapp:latest".to_string(),
652            })
653            .cpu(ResourceSpec {
654                min: "0.5".to_string(),
655                desired: "1".to_string(),
656            })
657            .memory(ResourceSpec {
658                min: "512Mi".to_string(),
659                desired: "1Gi".to_string(),
660            })
661            .port(8080)
662            .permissions("container-execution".to_string())
663            .build();
664
665        let json = serde_json::to_value(&container).expect("container should serialize");
666        assert!(json.get("stopGracePeriodSeconds").is_none());
667    }
668
669    #[test]
670    fn test_stateful_container_with_storage() {
671        let container = Container::new("postgres".to_string())
672            .cluster("compute".to_string())
673            .code(ContainerCode::Image {
674                image: "postgres:16".to_string(),
675            })
676            .cpu(ResourceSpec {
677                min: "1".to_string(),
678                desired: "2".to_string(),
679            })
680            .memory(ResourceSpec {
681                min: "2Gi".to_string(),
682                desired: "4Gi".to_string(),
683            })
684            .port(5432)
685            .stateful(true)
686            .replicas(1)
687            .persistent_storage(PersistentStorage {
688                size: "100Gi".to_string(),
689                mount_path: "/var/lib/postgresql/data".to_string(),
690            })
691            .permissions("database".to_string())
692            .build();
693
694        assert_eq!(container.id(), "postgres");
695        assert!(container.stateful);
696        assert!(container.replicas.is_some());
697        assert!(container.persistent_storage.is_some());
698    }
699
700    #[test]
701    fn test_public_container() {
702        let container = Container::new("frontend".to_string())
703            .cluster("compute".to_string())
704            .code(ContainerCode::Image {
705                image: "frontend:latest".to_string(),
706            })
707            .cpu(ResourceSpec {
708                min: "0.25".to_string(),
709                desired: "0.5".to_string(),
710            })
711            .memory(ResourceSpec {
712                min: "256Mi".to_string(),
713                desired: "512Mi".to_string(),
714            })
715            .port(3000)
716            .public_endpoint(PublicEndpoint {
717                name: "web".to_string(),
718                port: 3000,
719                protocol: ExposeProtocol::Http,
720                host_label: None,
721                wildcard_subdomains: false,
722            })
723            .autoscaling(ContainerAutoscaling {
724                min: 2,
725                desired: 2,
726                max: 20,
727                target_cpu_percent: None,
728                target_memory_percent: None,
729                target_http_in_flight_per_replica: Some(50),
730                max_http_p95_latency_ms: Some(100.0),
731            })
732            .health_check(HealthCheck {
733                path: "/health".to_string(),
734                port: None,
735                method: "GET".to_string(),
736                timeout_seconds: 1,
737                failure_threshold: 3,
738            })
739            .permissions("frontend".to_string())
740            .build();
741
742        assert_eq!(container.ports[0].port, 3000);
743        assert_eq!(container.public_endpoints[0].name, "web");
744        assert!(container.health_check.is_some());
745    }
746
747    #[test]
748    fn test_public_container_endpoint_options() {
749        let container = Container::new("router".to_string())
750            .cluster("compute".to_string())
751            .code(ContainerCode::Image {
752                image: "router:latest".to_string(),
753            })
754            .cpu(ResourceSpec {
755                min: "0.25".to_string(),
756                desired: "0.5".to_string(),
757            })
758            .memory(ResourceSpec {
759                min: "256Mi".to_string(),
760                desired: "512Mi".to_string(),
761            })
762            .public_endpoint(PublicEndpoint {
763                name: "gateway".to_string(),
764                port: 8080,
765                protocol: ExposeProtocol::Http,
766                host_label: Some("gateway".to_string()),
767                wildcard_subdomains: true,
768            })
769            .permissions("router".to_string())
770            .build();
771
772        assert!(container.validate_public_endpoints().is_ok());
773        assert_eq!(container.ports.len(), 1);
774        assert_eq!(container.public_endpoints.len(), 1);
775        assert_eq!(
776            container.public_endpoints[0].host_label.as_deref(),
777            Some("gateway")
778        );
779        assert!(container.public_endpoints[0].wildcard_subdomains);
780    }
781
782    #[test]
783    fn test_public_container_rejects_invalid_host_label() {
784        let container = Container::new("router".to_string())
785            .cluster("compute".to_string())
786            .code(ContainerCode::Image {
787                image: "router:latest".to_string(),
788            })
789            .cpu(ResourceSpec {
790                min: "0.25".to_string(),
791                desired: "0.5".to_string(),
792            })
793            .memory(ResourceSpec {
794                min: "256Mi".to_string(),
795                desired: "512Mi".to_string(),
796            })
797            .public_endpoint(PublicEndpoint {
798                name: "gateway".to_string(),
799                port: 8080,
800                protocol: ExposeProtocol::Http,
801                host_label: Some("bad.label".to_string()),
802                wildcard_subdomains: true,
803            })
804            .permissions("router".to_string())
805            .build();
806
807        assert!(container.validate_public_endpoints().is_err());
808    }
809
810    #[test]
811    fn test_container_with_links() {
812        use crate::Storage;
813
814        let storage = Storage::new("data".to_string()).build();
815
816        let container = Container::new("worker".to_string())
817            .cluster("compute".to_string())
818            .code(ContainerCode::Image {
819                image: "worker:latest".to_string(),
820            })
821            .cpu(ResourceSpec {
822                min: "0.5".to_string(),
823                desired: "1".to_string(),
824            })
825            .memory(ResourceSpec {
826                min: "512Mi".to_string(),
827                desired: "1Gi".to_string(),
828            })
829            .port(8080)
830            .replicas(3)
831            .link(&storage)
832            .permissions("worker".to_string())
833            .build();
834
835        // Should have 2 dependencies: cluster + linked storage
836        let deps = container.get_dependencies();
837        assert_eq!(deps.len(), 2);
838    }
839
840    #[test]
841    fn test_container_validate_update_immutable_cluster() {
842        let container1 = Container::new("api".to_string())
843            .cluster("cluster-1".to_string())
844            .code(ContainerCode::Image {
845                image: "myapp:v1".to_string(),
846            })
847            .cpu(ResourceSpec {
848                min: "0.5".to_string(),
849                desired: "1".to_string(),
850            })
851            .memory(ResourceSpec {
852                min: "512Mi".to_string(),
853                desired: "1Gi".to_string(),
854            })
855            .port(8080)
856            .replicas(2)
857            .permissions("execution".to_string())
858            .build();
859
860        let container2 = Container::new("api".to_string())
861            .cluster("cluster-2".to_string()) // Changed cluster
862            .code(ContainerCode::Image {
863                image: "myapp:v2".to_string(),
864            })
865            .cpu(ResourceSpec {
866                min: "0.5".to_string(),
867                desired: "1".to_string(),
868            })
869            .memory(ResourceSpec {
870                min: "512Mi".to_string(),
871                desired: "1Gi".to_string(),
872            })
873            .port(8080)
874            .replicas(2)
875            .permissions("execution".to_string())
876            .build();
877
878        let result = container1.validate_update(&container2);
879        assert!(result.is_err());
880    }
881
882    #[test]
883    fn test_container_validate_update_allowed_changes() {
884        let container1 = Container::new("api".to_string())
885            .cluster("compute".to_string())
886            .code(ContainerCode::Image {
887                image: "myapp:v1".to_string(),
888            })
889            .cpu(ResourceSpec {
890                min: "0.5".to_string(),
891                desired: "1".to_string(),
892            })
893            .memory(ResourceSpec {
894                min: "512Mi".to_string(),
895                desired: "1Gi".to_string(),
896            })
897            .port(8080)
898            .replicas(2)
899            .permissions("execution".to_string())
900            .build();
901
902        let container2 = Container::new("api".to_string())
903            .cluster("compute".to_string())
904            .code(ContainerCode::Image {
905                image: "myapp:v2".to_string(), // Image can change
906            })
907            .cpu(ResourceSpec {
908                min: "1".to_string(), // Resources can change
909                desired: "2".to_string(),
910            })
911            .memory(ResourceSpec {
912                min: "1Gi".to_string(),
913                desired: "2Gi".to_string(),
914            })
915            .port(8080)
916            .replicas(5) // Replicas can change
917            .permissions("execution".to_string())
918            .build();
919
920        let result = container1.validate_update(&container2);
921        assert!(result.is_ok());
922    }
923
924    #[test]
925    fn test_container_serialization() {
926        let container = Container::new("test".to_string())
927            .cluster("compute".to_string())
928            .code(ContainerCode::Image {
929                image: "test:latest".to_string(),
930            })
931            .cpu(ResourceSpec {
932                min: "0.5".to_string(),
933                desired: "1".to_string(),
934            })
935            .memory(ResourceSpec {
936                min: "512Mi".to_string(),
937                desired: "1Gi".to_string(),
938            })
939            .port(8080)
940            .replicas(1)
941            .permissions("test".to_string())
942            .build();
943
944        let json = serde_json::to_string(&container).unwrap();
945        let deserialized: Container = serde_json::from_str(&json).unwrap();
946        assert_eq!(container, deserialized);
947    }
948
949    #[test]
950    fn test_container_multi_endpoint_validation() {
951        let container = Container::new("multi-tcp".to_string())
952            .cluster("compute".to_string())
953            .code(ContainerCode::Image {
954                image: "test:latest".to_string(),
955            })
956            .cpu(ResourceSpec {
957                min: "1".to_string(),
958                desired: "1".to_string(),
959            })
960            .memory(ResourceSpec {
961                min: "1Gi".to_string(),
962                desired: "1Gi".to_string(),
963            })
964            .port(8080)
965            .public_endpoint(PublicEndpoint {
966                name: "api".to_string(),
967                port: 8080,
968                protocol: ExposeProtocol::Http,
969                host_label: None,
970                wildcard_subdomains: false,
971            })
972            .public_endpoint(PublicEndpoint {
973                name: "wildcard".to_string(),
974                port: 8080,
975                protocol: ExposeProtocol::Http,
976                host_label: Some("wildcard".to_string()),
977                wildcard_subdomains: true,
978            })
979            .replicas(1)
980            .permissions("test".to_string())
981            .build();
982
983        assert!(container.validate_public_endpoints().is_ok());
984
985        let invalid_container = Container::new("multi-http".to_string())
986            .cluster("compute".to_string())
987            .code(ContainerCode::Image {
988                image: "test:latest".to_string(),
989            })
990            .cpu(ResourceSpec {
991                min: "1".to_string(),
992                desired: "1".to_string(),
993            })
994            .memory(ResourceSpec {
995                min: "1Gi".to_string(),
996                desired: "1Gi".to_string(),
997            })
998            .port(8080)
999            .port(9090)
1000            .public_endpoint(PublicEndpoint {
1001                name: "api".to_string(),
1002                port: 8080,
1003                protocol: ExposeProtocol::Http,
1004                host_label: None,
1005                wildcard_subdomains: false,
1006            })
1007            .public_endpoint(PublicEndpoint {
1008                name: "admin".to_string(),
1009                port: 9090,
1010                protocol: ExposeProtocol::Http,
1011                host_label: None,
1012                wildcard_subdomains: false,
1013            })
1014            .replicas(1)
1015            .permissions("test".to_string())
1016            .build();
1017
1018        assert!(invalid_container.validate_public_endpoints().is_err());
1019    }
1020
1021    #[test]
1022    fn container_rejects_multiple_apex_public_endpoints() {
1023        let container = Container::new("apex-container".to_string())
1024            .cluster("compute".to_string())
1025            .code(ContainerCode::Image {
1026                image: "test:latest".to_string(),
1027            })
1028            .cpu(ResourceSpec {
1029                min: "1".to_string(),
1030                desired: "1".to_string(),
1031            })
1032            .memory(ResourceSpec {
1033                min: "1Gi".to_string(),
1034                desired: "1Gi".to_string(),
1035            })
1036            .port(8080)
1037            .public_endpoint(PublicEndpoint {
1038                name: "web".to_string(),
1039                port: 8080,
1040                protocol: ExposeProtocol::Http,
1041                host_label: Some(APEX_HOST_LABEL.to_string()),
1042                wildcard_subdomains: false,
1043            })
1044            .public_endpoint(PublicEndpoint {
1045                name: "admin".to_string(),
1046                port: 8080,
1047                protocol: ExposeProtocol::Http,
1048                host_label: Some(APEX_HOST_LABEL.to_string()),
1049                wildcard_subdomains: false,
1050            })
1051            .replicas(1)
1052            .permissions("test".to_string())
1053            .build();
1054
1055        assert!(container.validate_public_endpoints().is_err());
1056    }
1057
1058    #[test]
1059    fn test_container_empty_ports_validation() {
1060        let container = Container::new("no-ports".to_string())
1061            .cluster("compute".to_string())
1062            .code(ContainerCode::Image {
1063                image: "test:latest".to_string(),
1064            })
1065            .cpu(ResourceSpec {
1066                min: "1".to_string(),
1067                desired: "1".to_string(),
1068            })
1069            .memory(ResourceSpec {
1070                min: "1Gi".to_string(),
1071                desired: "1Gi".to_string(),
1072            })
1073            .replicas(1)
1074            .permissions("test".to_string())
1075            .build();
1076
1077        assert!(container.validate_public_endpoints().is_ok());
1078    }
1079}