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