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