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/// Allowed deployment-time resource quantities. These are choices, not autoscaling targets.
59#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
60#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
61#[serde(rename_all = "camelCase", deny_unknown_fields)]
62pub struct ResourceChoiceRange {
63    /// Smallest permitted allocation.
64    pub min: String,
65    /// Largest permitted allocation.
66    pub max: String,
67    /// Allocation used when deployment settings omit a selection.
68    pub default: String,
69}
70
71/// Release-declared choices for each container resource dimension.
72#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
73#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
74#[serde(rename_all = "camelCase", deny_unknown_fields)]
75pub struct ContainerResourceChoices {
76    /// Allowed CPU allocation in vCPUs or millicores.
77    #[serde(default, skip_serializing_if = "Option::is_none")]
78    pub cpu: Option<ResourceChoiceRange>,
79    /// Allowed memory allocation in binary units.
80    #[serde(default, skip_serializing_if = "Option::is_none")]
81    pub memory: Option<ResourceChoiceRange>,
82}
83
84/// GPU specification for a container.
85#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
86#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
87#[serde(rename_all = "camelCase")]
88pub struct ContainerGpuSpec {
89    /// GPU type identifier (e.g., "nvidia-a100", "nvidia-t4")
90    #[serde(rename = "type")]
91    pub gpu_type: String,
92    /// Number of GPUs required (1-8)
93    pub count: u32,
94}
95
96/// Persistent storage configuration for stateful containers.
97#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
98#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
99#[serde(rename_all = "camelCase")]
100pub struct PersistentStorage {
101    /// Storage size (e.g., "100Gi", "500Gi")
102    pub size: String,
103    /// Mount path inside the container
104    pub mount_path: String,
105    /// Key used to encrypt this volume and its snapshots. Cannot be changed after creation.
106    #[serde(default, skip_serializing_if = "Option::is_none")]
107    pub encryption_key: Option<ResourceRef>,
108    /// Scheduled snapshots of each replica's volume. On by default.
109    #[serde(default)]
110    pub backups: VolumeBackups,
111}
112
113/// Hours between snapshots that every cloud's native snapshot scheduler supports.
114pub const VOLUME_BACKUP_INTERVAL_HOURS: [u32; 7] = [1, 2, 4, 6, 8, 12, 24];
115
116/// Most snapshots a volume may hold at once. Azure Disk Backup keeps at most 450
117/// scheduled snapshots per disk; the same limit applies everywhere so a stack is
118/// portable across clouds.
119pub const VOLUME_BACKUP_MAX_SNAPSHOTS: u32 = 450;
120
121/// Longest a snapshot may be kept. Azure Disk Backup keeps operational snapshots
122/// for at most a year; the same limit applies everywhere for portability.
123pub const VOLUME_BACKUP_MAX_RETENTION_DAYS: u32 = 365;
124
125/// Scheduled snapshots of a persistent volume.
126///
127/// The cloud's own scheduler takes the snapshots (AWS Data Lifecycle Manager,
128/// a Compute Engine snapshot schedule, or Azure Disk Backup), so they keep being
129/// taken while the deployment is unreachable. Snapshots are crash-consistent:
130/// the volume as it would be after a sudden power loss. When a container is
131/// deleted, Alien keeps one final snapshot of each volume.
132///
133/// Kubernetes deployments leave volume backups to the cluster's own tooling,
134/// and local deployments don't snapshot volumes. Neither reports volumes in
135/// `ContainerOutputs.volumes` or a `volumeBackups` status.
136#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
137#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
138#[serde(rename_all = "camelCase")]
139pub struct VolumeBackups {
140    /// Whether snapshots are taken. Defaults to true.
141    #[serde(default = "default_volume_backups_enabled")]
142    pub enabled: bool,
143    /// Hours between snapshots: 1, 2, 4, 6, 8, 12 or 24. Defaults to 24.
144    #[serde(default = "default_volume_backup_interval_hours")]
145    pub interval_hours: u32,
146    /// Days each snapshot is kept, at most 365. Defaults to 7.
147    #[serde(default = "default_volume_backup_retention_days")]
148    pub retention_days: u32,
149}
150
151fn default_volume_backups_enabled() -> bool {
152    true
153}
154
155fn default_volume_backup_interval_hours() -> u32 {
156    24
157}
158
159fn default_volume_backup_retention_days() -> u32 {
160    7
161}
162
163impl Default for VolumeBackups {
164    fn default() -> Self {
165        Self {
166            enabled: default_volume_backups_enabled(),
167            interval_hours: default_volume_backup_interval_hours(),
168            retention_days: default_volume_backup_retention_days(),
169        }
170    }
171}
172
173impl VolumeBackups {
174    /// Backups switched off.
175    pub fn disabled() -> Self {
176        Self {
177            enabled: false,
178            ..Self::default()
179        }
180    }
181
182    /// Explains why this schedule can't run on every cloud, or `None` when it can.
183    pub fn validation_error(&self) -> Option<String> {
184        if !self.enabled {
185            return None;
186        }
187        if !VOLUME_BACKUP_INTERVAL_HOURS.contains(&self.interval_hours) {
188            return Some(format!(
189                "backup intervalHours must be one of {VOLUME_BACKUP_INTERVAL_HOURS:?}, got {}",
190                self.interval_hours
191            ));
192        }
193        if self.retention_days == 0 {
194            return Some("backup retentionDays must be at least 1".to_string());
195        }
196        if self.retention_days > VOLUME_BACKUP_MAX_RETENTION_DAYS {
197            return Some(format!(
198                "backup retentionDays must be at most {VOLUME_BACKUP_MAX_RETENTION_DAYS}, got {}",
199                self.retention_days
200            ));
201        }
202        let snapshots = self.retention_days * 24 / self.interval_hours;
203        if snapshots > VOLUME_BACKUP_MAX_SNAPSHOTS {
204            return Some(format!(
205                "backups every {} hours for {} days keep {snapshots} snapshots per volume; \
206                 the most is {VOLUME_BACKUP_MAX_SNAPSHOTS}",
207                self.interval_hours, self.retention_days
208            ));
209        }
210        None
211    }
212}
213
214/// Mounts an existing, setup-owned Kubernetes Secret into a Container pod.
215/// The Secret must exist in the deployment namespace before the workload starts.
216#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
217#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
218#[serde(rename_all = "camelCase")]
219pub struct KubernetesSecretMount {
220    /// Name of the existing Secret in the deployment namespace.
221    pub secret_name: String,
222    /// Directory where Kubernetes mounts the Secret's keys as read-only files.
223    pub mount_path: String,
224}
225
226/// HTTP probe used by Kubernetes for workload liveness or readiness.
227#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
228#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
229#[serde(rename_all = "camelCase")]
230pub struct KubernetesHttpProbe {
231    /// Absolute HTTP path served by the container.
232    pub path: String,
233    /// Container port to check.
234    pub port: u16,
235}
236
237/// Security profile shared by container runtimes.
238#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
239#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
240#[serde(rename_all = "camelCase")]
241pub enum ContainerSecurityProfile {
242    Restricted,
243}
244
245/// Container process identity and filesystem security.
246#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
247#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
248#[serde(rename_all = "camelCase")]
249pub struct ContainerSecurity {
250    /// Runtime security profile.
251    pub profile: ContainerSecurityProfile,
252    /// Numeric UID for the container process.
253    pub run_as_user: i64,
254    /// Numeric GID for the container process.
255    pub run_as_group: i64,
256    /// Mount the root filesystem read-only.
257    pub read_only_root_filesystem: bool,
258}
259
260/// Autoscaling configuration for stateless containers.
261#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
262#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
263#[serde(rename_all = "camelCase")]
264pub struct ContainerAutoscaling {
265    /// Minimum replicas (always running)
266    pub min: u32,
267    /// Initial desired replicas at container creation
268    pub desired: u32,
269    /// Maximum replicas under load
270    pub max: u32,
271    /// Target CPU utilization percentage for scaling (default: 70%)
272    #[serde(skip_serializing_if = "Option::is_none")]
273    pub target_cpu_percent: Option<f64>,
274    /// Target memory utilization percentage for scaling (default: 80%)
275    #[serde(skip_serializing_if = "Option::is_none")]
276    pub target_memory_percent: Option<f64>,
277    /// Target in-flight HTTP requests per replica
278    #[serde(skip_serializing_if = "Option::is_none")]
279    pub target_http_in_flight_per_replica: Option<u32>,
280    /// Maximum acceptable p95 HTTP latency in milliseconds
281    #[serde(skip_serializing_if = "Option::is_none")]
282    pub max_http_p95_latency_ms: Option<f64>,
283}
284
285/// HTTP health check configuration.
286#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
287#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
288#[serde(rename_all = "camelCase")]
289pub struct HealthCheck {
290    /// HTTP endpoint path to check (e.g., "/health", "/ready")
291    #[serde(default = "default_health_path")]
292    pub path: String,
293    /// Port to check (defaults to container port if not specified)
294    #[serde(skip_serializing_if = "Option::is_none")]
295    pub port: Option<u16>,
296    /// HTTP method to use for health check
297    #[serde(default = "default_health_method")]
298    pub method: String,
299    /// Request timeout in seconds (1-5)
300    #[serde(default = "default_timeout_seconds")]
301    pub timeout_seconds: u32,
302    /// Number of consecutive failures before marking replica unhealthy
303    #[serde(default = "default_failure_threshold")]
304    pub failure_threshold: u32,
305}
306
307fn default_health_path() -> String {
308    "/health".to_string()
309}
310
311fn default_health_method() -> String {
312    "GET".to_string()
313}
314
315fn default_timeout_seconds() -> u32 {
316    1
317}
318
319fn default_failure_threshold() -> u32 {
320    3
321}
322
323/// Container port configuration.
324#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
325#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
326#[serde(rename_all = "camelCase")]
327pub struct ContainerPort {
328    /// Port number
329    pub port: u16,
330}
331
332/// Container resource for running long-running container workloads.
333///
334/// A Container defines a deployable unit that runs on a ComputeCluster.
335/// The managed container backend handles scheduling replicas across machines,
336/// autoscaling based on various metrics, and service discovery.
337///
338/// ## Example
339///
340/// ```rust
341/// use alien_core::{Container, ContainerCode, ResourceSpec, ContainerAutoscaling, PublicEndpoint, ExposeProtocol};
342///
343/// let container = Container::new("api".to_string())
344///     .cluster("compute".to_string())
345///     .code(ContainerCode::Image {
346///         image: "myapp:latest".to_string(),
347///     })
348///     .cpu(ResourceSpec { min: "0.5".to_string(), desired: "1".to_string() })
349///     .memory(ResourceSpec { min: "512Mi".to_string(), desired: "1Gi".to_string() })
350///     .port(8080)
351///     .public_endpoint(PublicEndpoint {
352///         name: "api".to_string(),
353///         port: 8080,
354///         protocol: ExposeProtocol::Http,
355///         host_label: None,
356///         wildcard_subdomains: false,
357///     })
358///     .autoscaling(ContainerAutoscaling {
359///         min: 2,
360///         desired: 3,
361///         max: 10,
362///         target_cpu_percent: Some(70.0),
363///         target_memory_percent: None,
364///         target_http_in_flight_per_replica: Some(100),
365///         max_http_p95_latency_ms: None,
366///     })
367///     .permissions("container-execution".to_string())
368///     .build();
369/// ```
370#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Builder)]
371#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
372#[serde(rename_all = "camelCase", deny_unknown_fields)]
373#[builder(start_fn = new)]
374pub struct Container {
375    /// Unique identifier for the container.
376    /// Must be DNS-compatible: lowercase alphanumeric with hyphens.
377    #[builder(start_fn)]
378    pub id: String,
379
380    /// Resource links (dependencies)
381    #[builder(field)]
382    pub links: Vec<ResourceRef>,
383
384    /// Internal container ports (at least one required).
385    #[builder(field)]
386    pub ports: Vec<ContainerPort>,
387
388    /// Existing Kubernetes Secrets mounted as read-only directories.
389    #[builder(field)]
390    #[serde(default, skip_serializing_if = "Vec::is_empty")]
391    pub kubernetes_secret_mounts: Vec<KubernetesSecretMount>,
392
393    /// Public endpoints exposed by the container.
394    #[builder(field)]
395    #[serde(default, skip_serializing_if = "Vec::is_empty")]
396    pub public_endpoints: Vec<PublicEndpoint>,
397
398    /// Makes one HTTP port reachable from the control plane through the
399    /// manager's tunnel, over the operator's outbound connection. Nothing is
400    /// exposed on the deployment's network.
401    #[serde(default, skip_serializing_if = "Option::is_none")]
402    pub tunnel: Option<ContainerTunnel>,
403
404    /// Kubernetes liveness probe. Restarts an unhealthy container.
405    #[serde(skip_serializing_if = "Option::is_none")]
406    pub kubernetes_liveness_probe: Option<KubernetesHttpProbe>,
407
408    /// Kubernetes readiness probe. Removes an unready pod from Service endpoints.
409    #[serde(skip_serializing_if = "Option::is_none")]
410    pub kubernetes_readiness_probe: Option<KubernetesHttpProbe>,
411
412    /// Security settings shared by supported container runtimes.
413    #[serde(skip_serializing_if = "Option::is_none")]
414    pub security: Option<ContainerSecurity>,
415
416    /// ComputeCluster resource ID that this container runs on.
417    /// If None, will be auto-assigned by ComputeClusterMutation at deployment time.
418    #[serde(skip_serializing_if = "Option::is_none")]
419    pub cluster: Option<String>,
420
421    /// Container code (image or source)
422    pub code: ContainerCode,
423
424    /// CPU resource requirements
425    pub cpu: ResourceSpec,
426
427    /// Memory resource requirements (must use Ki/Mi/Gi/Ti suffix)
428    pub memory: ResourceSpec,
429
430    /// Optional deployment-time CPU and memory choices. Fixed declarations stay fixed.
431    #[serde(default, skip_serializing_if = "Option::is_none")]
432    pub resource_choices: Option<ContainerResourceChoices>,
433
434    /// GPU requirements (optional)
435    #[serde(skip_serializing_if = "Option::is_none")]
436    pub gpu: Option<ContainerGpuSpec>,
437
438    /// Ephemeral storage requirement (e.g., "10Gi")
439    #[serde(skip_serializing_if = "Option::is_none")]
440    pub ephemeral_storage: Option<String>,
441
442    /// Persistent storage configuration (only for stateful containers)
443    #[serde(skip_serializing_if = "Option::is_none")]
444    pub persistent_storage: Option<PersistentStorage>,
445
446    /// Fixed replica count (for stateful containers or stateless without autoscaling)
447    #[serde(skip_serializing_if = "Option::is_none")]
448    pub replicas: Option<u32>,
449
450    /// Autoscaling configuration (only for stateless containers)
451    #[serde(skip_serializing_if = "Option::is_none")]
452    pub autoscaling: Option<ContainerAutoscaling>,
453
454    /// Whether container is stateful (gets stable ordinals, optional persistent volumes)
455    #[builder(default = false)]
456    #[serde(default)]
457    pub stateful: bool,
458
459    /// Environment variables
460    #[builder(default)]
461    #[serde(default)]
462    pub environment: HashMap<String, String>,
463
464    /// Capacity group to run on (must exist in the cluster)
465    /// If not specified, containers are scheduled to any available group.
466    #[serde(skip_serializing_if = "Option::is_none")]
467    pub pool: Option<String>,
468
469    /// Permission profile name
470    pub permissions: String,
471
472    /// Health check configuration
473    #[serde(skip_serializing_if = "Option::is_none")]
474    pub health_check: Option<HealthCheck>,
475
476    /// Command to override image default
477    #[serde(skip_serializing_if = "Option::is_none")]
478    pub command: Option<Vec<String>>,
479
480    /// Whether the container can receive remote commands via the Commands protocol.
481    /// When enabled, an app-owned command receiver can lease pending commands
482    /// for this Container and execute registered handlers.
483    #[builder(default = default_commands_enabled())]
484    #[serde(default = "default_commands_enabled")]
485    #[cfg_attr(feature = "openapi", schema(default = default_commands_enabled))]
486    pub commands_enabled: bool,
487
488    /// Grace period in seconds for stopping replicas during updates, drains, and deletes.
489    ///
490    /// When omitted, the runtime backend applies its default. Valid values are
491    /// 1 second through 24 hours.
492    #[serde(skip_serializing_if = "Option::is_none")]
493    #[cfg_attr(feature = "openapi", schema(minimum = 1, maximum = 86400))]
494    pub stop_grace_period_seconds: Option<u32>,
495}
496
497impl Container {
498    /// The resource type identifier for Container
499    pub const RESOURCE_TYPE: ResourceType = ResourceType::from_static("container");
500
501    /// Returns the container's unique identifier.
502    pub fn id(&self) -> &str {
503        &self.id
504    }
505
506    /// Returns the permission profile name for this container.
507    pub fn get_permissions(&self) -> &str {
508        &self.permissions
509    }
510
511    /// Returns true if this container is stateless (not stateful).
512    pub fn is_stateless(&self) -> bool {
513        !self.stateful
514    }
515
516    /// Validates the public endpoint configuration.
517    fn validate_public_endpoints(&self) -> Result<()> {
518        let mut endpoint_names = std::collections::HashSet::new();
519        let mut backend_ports = std::collections::HashSet::new();
520        let mut apex_endpoint_name: Option<&str> = None;
521
522        for endpoint in &self.public_endpoints {
523            endpoint.validate_for_resource(&self.id)?;
524
525            if !endpoint_names.insert(endpoint.name.as_str()) {
526                return Err(AlienError::new(ErrorData::InvalidResourceUpdate {
527                    resource_id: self.id.clone(),
528                    reason: format!("duplicate public endpoint name '{}'", endpoint.name),
529                }));
530            }
531
532            if endpoint.host_label.as_deref() == Some(APEX_HOST_LABEL) {
533                if let Some(existing_name) = apex_endpoint_name {
534                    return Err(AlienError::new(ErrorData::InvalidResourceUpdate {
535                        resource_id: self.id.clone(),
536                        reason: format!(
537                            "only one apex public endpoint is allowed per resource; '{}' already uses hostLabel '@'",
538                            existing_name
539                        ),
540                    }));
541                }
542                apex_endpoint_name = Some(endpoint.name.as_str());
543            }
544
545            backend_ports.insert(endpoint.port);
546
547            if !self.ports.iter().any(|port| port.port == endpoint.port) {
548                return Err(AlienError::new(ErrorData::InvalidResourceUpdate {
549                    resource_id: self.id.clone(),
550                    reason: format!(
551                        "public endpoint '{}' references undeclared port {}",
552                        endpoint.name, endpoint.port
553                    ),
554                }));
555            }
556        }
557
558        if backend_ports.len() > 1 {
559            return Err(AlienError::new(ErrorData::InvalidResourceUpdate {
560                resource_id: self.id.clone(),
561                reason:
562                    "public endpoints on one container must currently route to the same backend port"
563                        .to_string(),
564            }));
565        }
566
567        Ok(())
568    }
569}
570
571fn default_commands_enabled() -> bool {
572    false
573}
574
575impl<S: container_builder::State> ContainerBuilder<S> {
576    /// Links the container to another resource with specified permissions.
577    pub fn link<R: ?Sized>(mut self, resource: &R) -> Self
578    where
579        for<'a> &'a R: Into<ResourceRef>,
580    {
581        let resource_ref: ResourceRef = resource.into();
582        self.links.push(resource_ref);
583        self
584    }
585
586    /// Adds an internal-only port to the container.
587    pub fn port(mut self, port: u16) -> Self {
588        self.ports.push(ContainerPort { port });
589        self
590    }
591
592    /// Mounts an existing Kubernetes Secret without copying its value into the stack.
593    pub fn kubernetes_secret_mount(mut self, mount: KubernetesSecretMount) -> Self {
594        self.kubernetes_secret_mounts.push(mount);
595        self
596    }
597
598    /// Exposes a named public endpoint.
599    pub fn public_endpoint(mut self, endpoint: PublicEndpoint) -> Self {
600        if !self.ports.iter().any(|p| p.port == endpoint.port) {
601            self.ports.push(ContainerPort {
602                port: endpoint.port,
603            });
604        }
605        self.public_endpoints.push(endpoint);
606        self
607    }
608}
609
610/// Container status in the managed container backend.
611#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
612#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
613#[serde(rename_all = "camelCase")]
614pub enum ContainerStatus {
615    /// Waiting for replicas to start
616    Pending,
617    /// Min replicas healthy and serving
618    Running,
619    /// Manually stopped
620    Stopped,
621    /// Something is wrong — see statusReason/statusMessage; scheduler keeps retrying.
622    /// Covers all failure modes: crash-looping, unschedulable, replica failures, etc.
623    Failing,
624}
625
626/// Status of a single container replica.
627#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
628#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
629#[serde(rename_all = "camelCase")]
630pub struct ReplicaStatus {
631    /// Replica ID (e.g., "api-0", "api-1")
632    pub replica_id: String,
633    /// Ordinal (for stateful containers)
634    pub ordinal: Option<u32>,
635    /// Machine ID the replica is running on
636    pub machine_id: Option<String>,
637    /// Whether the replica is healthy
638    pub healthy: bool,
639    /// Container IP address (for service discovery)
640    pub container_ip: Option<String>,
641}
642
643/// A container port reachable from the control plane through the manager.
644///
645/// Requests to `/v1/deployments/{deployment}/tunnels/{container}/...` on the
646/// manager are forwarded to this port over the operator's outbound
647/// connection, so the control plane can call the service without any inbound
648/// network path into the deployment.
649#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
650#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
651#[serde(rename_all = "camelCase", deny_unknown_fields)]
652pub struct ContainerTunnel {
653    /// Container port that serves HTTP. Must be one of the container's ports.
654    pub port: u16,
655}
656
657/// Outputs generated by a successfully provisioned Container.
658#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
659#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
660#[serde(rename_all = "camelCase")]
661pub struct ContainerOutputs {
662    /// Container name in the managed container backend
663    pub name: String,
664    /// Current container status
665    pub status: ContainerStatus,
666    /// Number of current replicas
667    pub current_replicas: u32,
668    /// Desired number of replicas
669    pub desired_replicas: u32,
670    /// Internal DNS name (e.g., "api.svc")
671    pub internal_dns: String,
672    /// Public endpoints resolved for this container.
673    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
674    pub public_endpoints: HashMap<String, PublicEndpointOutput>,
675    /// Status of each replica
676    pub replicas: Vec<ReplicaStatus>,
677    /// Persistent volumes, one per replica ordinal, with their latest snapshot.
678    #[serde(default, skip_serializing_if = "Vec::is_empty")]
679    pub volumes: Vec<VolumeOutput>,
680    /// Whether the persistent volumes' snapshot schedule is in place.
681    #[serde(default, skip_serializing_if = "Option::is_none")]
682    pub volume_backups: Option<VolumeBackupsStatus>,
683}
684
685/// Whether a container's snapshot schedule is in place.
686#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
687#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
688#[serde(rename_all = "camelCase")]
689pub struct VolumeBackupsStatus {
690    /// Current state of the schedule
691    pub state: VolumeBackupsState,
692    /// What is missing when the state is `setupRequired`
693    #[serde(skip_serializing_if = "Option::is_none")]
694    pub message: Option<String>,
695}
696
697/// State of a container's snapshot schedule.
698#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
699#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
700#[serde(rename_all = "camelCase")]
701pub enum VolumeBackupsState {
702    /// The cloud's scheduler is taking snapshots.
703    Active,
704    /// Backups are turned off in the stack.
705    Disabled,
706    /// The deployment's management permissions predate volume backups. The
707    /// container keeps running without a schedule until the installation's
708    /// setup is updated; the controller then applies the schedule by itself.
709    SetupRequired,
710}
711
712/// A replica's persistent volume and its latest completed snapshot.
713#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
714#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
715#[serde(rename_all = "camelCase")]
716pub struct VolumeOutput {
717    /// Replica ordinal the volume belongs to
718    pub ordinal: u32,
719    /// Cloud ID of the volume (EBS volume ID, Persistent Disk name, or Managed Disk ID)
720    pub volume_id: String,
721    /// Zone the volume lives in
722    pub zone: String,
723    /// Cloud ID of the latest completed snapshot, if any
724    #[serde(skip_serializing_if = "Option::is_none")]
725    pub last_snapshot_id: Option<String>,
726    /// When the latest completed snapshot was started (RFC 3339)
727    #[serde(skip_serializing_if = "Option::is_none")]
728    pub last_snapshot_at: Option<String>,
729    /// The latest volume restore performed on this ordinal, if any
730    #[serde(skip_serializing_if = "Option::is_none")]
731    pub last_restore: Option<VolumeRestoreOutput>,
732}
733
734/// A completed volume restore.
735#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
736#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
737#[serde(rename_all = "camelCase")]
738pub struct VolumeRestoreOutput {
739    /// ID of the restore request
740    pub request_id: String,
741    /// Snapshot the volume was restored from
742    pub snapshot_id: String,
743    /// Snapshot of the replaced volume, taken just before it was deleted
744    pub replaced_volume_snapshot_id: String,
745    /// When the restored volume was put in place (RFC 3339)
746    pub completed_at: String,
747}
748
749impl ResourceOutputsDefinition for ContainerOutputs {
750    fn get_resource_type(&self) -> ResourceType {
751        Container::RESOURCE_TYPE.clone()
752    }
753
754    fn as_any(&self) -> &dyn Any {
755        self
756    }
757
758    fn box_clone(&self) -> Box<dyn ResourceOutputsDefinition> {
759        Box::new(self.clone())
760    }
761
762    fn outputs_eq(&self, other: &dyn ResourceOutputsDefinition) -> bool {
763        other.as_any().downcast_ref::<ContainerOutputs>() == Some(self)
764    }
765
766    fn to_json_value(&self) -> serde_json::Result<serde_json::Value> {
767        serde_json::to_value(self)
768    }
769}
770
771impl ResourceDefinition for Container {
772    fn get_resource_type(&self) -> ResourceType {
773        Self::RESOURCE_TYPE
774    }
775
776    fn id(&self) -> &str {
777        &self.id
778    }
779
780    fn replace_after_failed_create_is_safe(&self) -> bool {
781        // Deleting it destroys the persistent volumes of a stateful container.
782        !(self.stateful && self.persistent_storage.is_some())
783    }
784
785    fn get_dependencies(&self) -> Vec<ResourceRef> {
786        let mut deps = self.links.clone();
787        if let Some(key) = self
788            .persistent_storage
789            .as_ref()
790            .and_then(|storage| storage.encryption_key.as_ref())
791        {
792            deps.push(key.clone());
793        }
794        // Add dependency on the container cluster if explicitly specified.
795        // If None, ComputeClusterMutation will auto-assign at deployment time.
796        if let Some(cluster) = &self.cluster {
797            deps.push(ResourceRef::new(
798                ComputeCluster::RESOURCE_TYPE.clone(),
799                cluster,
800            ));
801        }
802        deps
803    }
804
805    fn get_permissions(&self) -> Option<&str> {
806        Some(&self.permissions)
807    }
808
809    fn validate_update(&self, new_config: &dyn ResourceDefinition) -> Result<()> {
810        let new_container = new_config
811            .as_any()
812            .downcast_ref::<Container>()
813            .ok_or_else(|| {
814                AlienError::new(ErrorData::UnexpectedResourceType {
815                    resource_id: self.id.clone(),
816                    expected: Self::RESOURCE_TYPE,
817                    actual: new_config.get_resource_type(),
818                })
819            })?;
820
821        // Validate the new config's public endpoints.
822        new_container.validate_public_endpoints()?;
823
824        if self.id != new_container.id {
825            return Err(AlienError::new(ErrorData::InvalidResourceUpdate {
826                resource_id: self.id.clone(),
827                reason: "the 'id' field is immutable".to_string(),
828            }));
829        }
830
831        // Cluster is immutable
832        if self.cluster != new_container.cluster {
833            return Err(AlienError::new(ErrorData::InvalidResourceUpdate {
834                resource_id: self.id.clone(),
835                reason: "the 'cluster' field is immutable".to_string(),
836            }));
837        }
838
839        // Stateful is immutable
840        if self.stateful != new_container.stateful {
841            return Err(AlienError::new(ErrorData::InvalidResourceUpdate {
842                resource_id: self.id.clone(),
843                reason: "the 'stateful' field is immutable".to_string(),
844            }));
845        }
846
847        let old_key = self
848            .persistent_storage
849            .as_ref()
850            .and_then(|storage| storage.encryption_key.as_ref());
851        let new_key = new_container
852            .persistent_storage
853            .as_ref()
854            .and_then(|storage| storage.encryption_key.as_ref());
855        if old_key != new_key {
856            return Err(AlienError::new(ErrorData::InvalidResourceUpdate {
857                resource_id: self.id.clone(),
858                reason: "the 'persistentStorage.encryptionKey' field is immutable once the resource exists".to_string(),
859            }));
860        }
861
862        // Ports are immutable (requires load balancer reconfiguration)
863        if self.ports != new_container.ports {
864            return Err(AlienError::new(ErrorData::InvalidResourceUpdate {
865                resource_id: self.id.clone(),
866                reason: "the 'ports' field is immutable".to_string(),
867            }));
868        }
869
870        if self.public_endpoints != new_container.public_endpoints {
871            return Err(AlienError::new(ErrorData::InvalidResourceUpdate {
872                resource_id: self.id.clone(),
873                reason: "the 'publicEndpoints' field is immutable".to_string(),
874            }));
875        }
876
877        // Pool (capacity group) is immutable
878        if self.pool != new_container.pool {
879            return Err(AlienError::new(ErrorData::InvalidResourceUpdate {
880                resource_id: self.id.clone(),
881                reason: "the 'pool' field is immutable".to_string(),
882            }));
883        }
884
885        Ok(())
886    }
887
888    fn as_any(&self) -> &dyn Any {
889        self
890    }
891
892    fn as_any_mut(&mut self) -> &mut dyn Any {
893        self
894    }
895
896    fn box_clone(&self) -> Box<dyn ResourceDefinition> {
897        Box::new(self.clone())
898    }
899
900    fn resource_eq(&self, other: &dyn ResourceDefinition) -> bool {
901        other.as_any().downcast_ref::<Container>() == Some(self)
902    }
903
904    fn to_json_value(&self) -> serde_json::Result<serde_json::Value> {
905        serde_json::to_value(self)
906    }
907}
908
909#[cfg(test)]
910mod tests {
911    use super::*;
912    use crate::resources::ExposeProtocol;
913
914    #[test]
915    fn test_container_creation_with_autoscaling() {
916        let container = Container::new("api".to_string())
917            .cluster("compute".to_string())
918            .code(ContainerCode::Image {
919                image: "myapp:latest".to_string(),
920            })
921            .cpu(ResourceSpec {
922                min: "0.5".to_string(),
923                desired: "1".to_string(),
924            })
925            .memory(ResourceSpec {
926                min: "512Mi".to_string(),
927                desired: "1Gi".to_string(),
928            })
929            .port(8080)
930            .public_endpoint(PublicEndpoint {
931                name: "api".to_string(),
932                port: 8080,
933                protocol: ExposeProtocol::Http,
934                host_label: None,
935                wildcard_subdomains: false,
936            })
937            .autoscaling(ContainerAutoscaling {
938                min: 2,
939                desired: 3,
940                max: 10,
941                target_cpu_percent: Some(70.0),
942                target_memory_percent: None,
943                target_http_in_flight_per_replica: Some(100),
944                max_http_p95_latency_ms: None,
945            })
946            .permissions("container-execution".to_string())
947            .build();
948
949        assert_eq!(container.id(), "api");
950        assert_eq!(container.cluster, Some("compute".to_string()));
951        assert!(!container.stateful);
952        assert!(container.autoscaling.is_some());
953        assert_eq!(container.ports.len(), 1);
954        assert_eq!(container.ports[0].port, 8080);
955    }
956
957    #[test]
958    fn container_serializes_stop_grace_period_when_set() {
959        let container = Container::new("api".to_string())
960            .cluster("compute".to_string())
961            .code(ContainerCode::Image {
962                image: "myapp:latest".to_string(),
963            })
964            .cpu(ResourceSpec {
965                min: "0.5".to_string(),
966                desired: "1".to_string(),
967            })
968            .memory(ResourceSpec {
969                min: "512Mi".to_string(),
970                desired: "1Gi".to_string(),
971            })
972            .port(8080)
973            .permissions("container-execution".to_string())
974            .stop_grace_period_seconds(21_600)
975            .build();
976
977        let json = serde_json::to_value(&container).expect("container should serialize");
978        assert_eq!(json["stopGracePeriodSeconds"], 21_600);
979    }
980
981    #[test]
982    fn container_omits_stop_grace_period_when_absent() {
983        let container = Container::new("api".to_string())
984            .cluster("compute".to_string())
985            .code(ContainerCode::Image {
986                image: "myapp:latest".to_string(),
987            })
988            .cpu(ResourceSpec {
989                min: "0.5".to_string(),
990                desired: "1".to_string(),
991            })
992            .memory(ResourceSpec {
993                min: "512Mi".to_string(),
994                desired: "1Gi".to_string(),
995            })
996            .port(8080)
997            .permissions("container-execution".to_string())
998            .build();
999
1000        let json = serde_json::to_value(&container).expect("container should serialize");
1001        assert!(json.get("stopGracePeriodSeconds").is_none());
1002    }
1003
1004    #[test]
1005    fn test_stateful_container_with_storage() {
1006        let container = Container::new("postgres".to_string())
1007            .cluster("compute".to_string())
1008            .code(ContainerCode::Image {
1009                image: "postgres:16".to_string(),
1010            })
1011            .cpu(ResourceSpec {
1012                min: "1".to_string(),
1013                desired: "2".to_string(),
1014            })
1015            .memory(ResourceSpec {
1016                min: "2Gi".to_string(),
1017                desired: "4Gi".to_string(),
1018            })
1019            .port(5432)
1020            .stateful(true)
1021            .replicas(1)
1022            .persistent_storage(PersistentStorage {
1023                encryption_key: None,
1024                size: "100Gi".to_string(),
1025                mount_path: "/var/lib/postgresql/data".to_string(),
1026                backups: VolumeBackups::default(),
1027            })
1028            .permissions("database".to_string())
1029            .build();
1030
1031        assert_eq!(container.id(), "postgres");
1032        assert!(container.stateful);
1033        assert!(container.replicas.is_some());
1034        assert!(container.persistent_storage.is_some());
1035    }
1036
1037    #[test]
1038    fn test_public_container() {
1039        let container = Container::new("frontend".to_string())
1040            .cluster("compute".to_string())
1041            .code(ContainerCode::Image {
1042                image: "frontend:latest".to_string(),
1043            })
1044            .cpu(ResourceSpec {
1045                min: "0.25".to_string(),
1046                desired: "0.5".to_string(),
1047            })
1048            .memory(ResourceSpec {
1049                min: "256Mi".to_string(),
1050                desired: "512Mi".to_string(),
1051            })
1052            .port(3000)
1053            .public_endpoint(PublicEndpoint {
1054                name: "web".to_string(),
1055                port: 3000,
1056                protocol: ExposeProtocol::Http,
1057                host_label: None,
1058                wildcard_subdomains: false,
1059            })
1060            .autoscaling(ContainerAutoscaling {
1061                min: 2,
1062                desired: 2,
1063                max: 20,
1064                target_cpu_percent: None,
1065                target_memory_percent: None,
1066                target_http_in_flight_per_replica: Some(50),
1067                max_http_p95_latency_ms: Some(100.0),
1068            })
1069            .health_check(HealthCheck {
1070                path: "/health".to_string(),
1071                port: None,
1072                method: "GET".to_string(),
1073                timeout_seconds: 1,
1074                failure_threshold: 3,
1075            })
1076            .permissions("frontend".to_string())
1077            .build();
1078
1079        assert_eq!(container.ports[0].port, 3000);
1080        assert_eq!(container.public_endpoints[0].name, "web");
1081        assert!(container.health_check.is_some());
1082    }
1083
1084    #[test]
1085    fn test_public_container_endpoint_options() {
1086        let container = Container::new("router".to_string())
1087            .cluster("compute".to_string())
1088            .code(ContainerCode::Image {
1089                image: "router:latest".to_string(),
1090            })
1091            .cpu(ResourceSpec {
1092                min: "0.25".to_string(),
1093                desired: "0.5".to_string(),
1094            })
1095            .memory(ResourceSpec {
1096                min: "256Mi".to_string(),
1097                desired: "512Mi".to_string(),
1098            })
1099            .public_endpoint(PublicEndpoint {
1100                name: "gateway".to_string(),
1101                port: 8080,
1102                protocol: ExposeProtocol::Http,
1103                host_label: Some("gateway".to_string()),
1104                wildcard_subdomains: true,
1105            })
1106            .permissions("router".to_string())
1107            .build();
1108
1109        assert!(container.validate_public_endpoints().is_ok());
1110        assert_eq!(container.ports.len(), 1);
1111        assert_eq!(container.public_endpoints.len(), 1);
1112        assert_eq!(
1113            container.public_endpoints[0].host_label.as_deref(),
1114            Some("gateway")
1115        );
1116        assert!(container.public_endpoints[0].wildcard_subdomains);
1117    }
1118
1119    #[test]
1120    fn test_public_container_rejects_invalid_host_label() {
1121        let container = Container::new("router".to_string())
1122            .cluster("compute".to_string())
1123            .code(ContainerCode::Image {
1124                image: "router:latest".to_string(),
1125            })
1126            .cpu(ResourceSpec {
1127                min: "0.25".to_string(),
1128                desired: "0.5".to_string(),
1129            })
1130            .memory(ResourceSpec {
1131                min: "256Mi".to_string(),
1132                desired: "512Mi".to_string(),
1133            })
1134            .public_endpoint(PublicEndpoint {
1135                name: "gateway".to_string(),
1136                port: 8080,
1137                protocol: ExposeProtocol::Http,
1138                host_label: Some("bad.label".to_string()),
1139                wildcard_subdomains: true,
1140            })
1141            .permissions("router".to_string())
1142            .build();
1143
1144        assert!(container.validate_public_endpoints().is_err());
1145    }
1146
1147    #[test]
1148    fn test_container_with_links() {
1149        use crate::Storage;
1150
1151        let storage = Storage::new("data".to_string()).build();
1152
1153        let container = Container::new("worker".to_string())
1154            .cluster("compute".to_string())
1155            .code(ContainerCode::Image {
1156                image: "worker:latest".to_string(),
1157            })
1158            .cpu(ResourceSpec {
1159                min: "0.5".to_string(),
1160                desired: "1".to_string(),
1161            })
1162            .memory(ResourceSpec {
1163                min: "512Mi".to_string(),
1164                desired: "1Gi".to_string(),
1165            })
1166            .port(8080)
1167            .replicas(3)
1168            .link(&storage)
1169            .permissions("worker".to_string())
1170            .build();
1171
1172        // Should have 2 dependencies: cluster + linked storage
1173        let deps = container.get_dependencies();
1174        assert_eq!(deps.len(), 2);
1175    }
1176
1177    #[test]
1178    fn test_container_validate_update_immutable_cluster() {
1179        let container1 = Container::new("api".to_string())
1180            .cluster("cluster-1".to_string())
1181            .code(ContainerCode::Image {
1182                image: "myapp:v1".to_string(),
1183            })
1184            .cpu(ResourceSpec {
1185                min: "0.5".to_string(),
1186                desired: "1".to_string(),
1187            })
1188            .memory(ResourceSpec {
1189                min: "512Mi".to_string(),
1190                desired: "1Gi".to_string(),
1191            })
1192            .port(8080)
1193            .replicas(2)
1194            .permissions("execution".to_string())
1195            .build();
1196
1197        let container2 = Container::new("api".to_string())
1198            .cluster("cluster-2".to_string()) // Changed cluster
1199            .code(ContainerCode::Image {
1200                image: "myapp:v2".to_string(),
1201            })
1202            .cpu(ResourceSpec {
1203                min: "0.5".to_string(),
1204                desired: "1".to_string(),
1205            })
1206            .memory(ResourceSpec {
1207                min: "512Mi".to_string(),
1208                desired: "1Gi".to_string(),
1209            })
1210            .port(8080)
1211            .replicas(2)
1212            .permissions("execution".to_string())
1213            .build();
1214
1215        let result = container1.validate_update(&container2);
1216        assert!(result.is_err());
1217    }
1218
1219    #[test]
1220    fn test_container_validate_update_allowed_changes() {
1221        let container1 = Container::new("api".to_string())
1222            .cluster("compute".to_string())
1223            .code(ContainerCode::Image {
1224                image: "myapp:v1".to_string(),
1225            })
1226            .cpu(ResourceSpec {
1227                min: "0.5".to_string(),
1228                desired: "1".to_string(),
1229            })
1230            .memory(ResourceSpec {
1231                min: "512Mi".to_string(),
1232                desired: "1Gi".to_string(),
1233            })
1234            .port(8080)
1235            .replicas(2)
1236            .permissions("execution".to_string())
1237            .build();
1238
1239        let container2 = Container::new("api".to_string())
1240            .cluster("compute".to_string())
1241            .code(ContainerCode::Image {
1242                image: "myapp:v2".to_string(), // Image can change
1243            })
1244            .cpu(ResourceSpec {
1245                min: "1".to_string(), // Resources can change
1246                desired: "2".to_string(),
1247            })
1248            .memory(ResourceSpec {
1249                min: "1Gi".to_string(),
1250                desired: "2Gi".to_string(),
1251            })
1252            .port(8080)
1253            .replicas(5) // Replicas can change
1254            .permissions("execution".to_string())
1255            .build();
1256
1257        let result = container1.validate_update(&container2);
1258        assert!(result.is_ok());
1259    }
1260
1261    #[test]
1262    fn test_container_serialization() {
1263        let container = Container::new("test".to_string())
1264            .cluster("compute".to_string())
1265            .code(ContainerCode::Image {
1266                image: "test:latest".to_string(),
1267            })
1268            .cpu(ResourceSpec {
1269                min: "0.5".to_string(),
1270                desired: "1".to_string(),
1271            })
1272            .memory(ResourceSpec {
1273                min: "512Mi".to_string(),
1274                desired: "1Gi".to_string(),
1275            })
1276            .port(8080)
1277            .replicas(1)
1278            .permissions("test".to_string())
1279            .build();
1280
1281        let json = serde_json::to_string(&container).unwrap();
1282        let deserialized: Container = serde_json::from_str(&json).unwrap();
1283        assert_eq!(container, deserialized);
1284    }
1285
1286    #[test]
1287    fn test_container_multi_endpoint_validation() {
1288        let container = Container::new("multi-tcp".to_string())
1289            .cluster("compute".to_string())
1290            .code(ContainerCode::Image {
1291                image: "test:latest".to_string(),
1292            })
1293            .cpu(ResourceSpec {
1294                min: "1".to_string(),
1295                desired: "1".to_string(),
1296            })
1297            .memory(ResourceSpec {
1298                min: "1Gi".to_string(),
1299                desired: "1Gi".to_string(),
1300            })
1301            .port(8080)
1302            .public_endpoint(PublicEndpoint {
1303                name: "api".to_string(),
1304                port: 8080,
1305                protocol: ExposeProtocol::Http,
1306                host_label: None,
1307                wildcard_subdomains: false,
1308            })
1309            .public_endpoint(PublicEndpoint {
1310                name: "wildcard".to_string(),
1311                port: 8080,
1312                protocol: ExposeProtocol::Http,
1313                host_label: Some("wildcard".to_string()),
1314                wildcard_subdomains: true,
1315            })
1316            .replicas(1)
1317            .permissions("test".to_string())
1318            .build();
1319
1320        assert!(container.validate_public_endpoints().is_ok());
1321
1322        let invalid_container = Container::new("multi-http".to_string())
1323            .cluster("compute".to_string())
1324            .code(ContainerCode::Image {
1325                image: "test:latest".to_string(),
1326            })
1327            .cpu(ResourceSpec {
1328                min: "1".to_string(),
1329                desired: "1".to_string(),
1330            })
1331            .memory(ResourceSpec {
1332                min: "1Gi".to_string(),
1333                desired: "1Gi".to_string(),
1334            })
1335            .port(8080)
1336            .port(9090)
1337            .public_endpoint(PublicEndpoint {
1338                name: "api".to_string(),
1339                port: 8080,
1340                protocol: ExposeProtocol::Http,
1341                host_label: None,
1342                wildcard_subdomains: false,
1343            })
1344            .public_endpoint(PublicEndpoint {
1345                name: "admin".to_string(),
1346                port: 9090,
1347                protocol: ExposeProtocol::Http,
1348                host_label: None,
1349                wildcard_subdomains: false,
1350            })
1351            .replicas(1)
1352            .permissions("test".to_string())
1353            .build();
1354
1355        assert!(invalid_container.validate_public_endpoints().is_err());
1356    }
1357
1358    #[test]
1359    fn container_rejects_multiple_apex_public_endpoints() {
1360        let container = Container::new("apex-container".to_string())
1361            .cluster("compute".to_string())
1362            .code(ContainerCode::Image {
1363                image: "test:latest".to_string(),
1364            })
1365            .cpu(ResourceSpec {
1366                min: "1".to_string(),
1367                desired: "1".to_string(),
1368            })
1369            .memory(ResourceSpec {
1370                min: "1Gi".to_string(),
1371                desired: "1Gi".to_string(),
1372            })
1373            .port(8080)
1374            .public_endpoint(PublicEndpoint {
1375                name: "web".to_string(),
1376                port: 8080,
1377                protocol: ExposeProtocol::Http,
1378                host_label: Some(APEX_HOST_LABEL.to_string()),
1379                wildcard_subdomains: false,
1380            })
1381            .public_endpoint(PublicEndpoint {
1382                name: "admin".to_string(),
1383                port: 8080,
1384                protocol: ExposeProtocol::Http,
1385                host_label: Some(APEX_HOST_LABEL.to_string()),
1386                wildcard_subdomains: false,
1387            })
1388            .replicas(1)
1389            .permissions("test".to_string())
1390            .build();
1391
1392        assert!(container.validate_public_endpoints().is_err());
1393    }
1394
1395    #[test]
1396    fn test_container_empty_ports_validation() {
1397        let container = Container::new("no-ports".to_string())
1398            .cluster("compute".to_string())
1399            .code(ContainerCode::Image {
1400                image: "test:latest".to_string(),
1401            })
1402            .cpu(ResourceSpec {
1403                min: "1".to_string(),
1404                desired: "1".to_string(),
1405            })
1406            .memory(ResourceSpec {
1407                min: "1Gi".to_string(),
1408                desired: "1Gi".to_string(),
1409            })
1410            .replicas(1)
1411            .permissions("test".to_string())
1412            .build();
1413
1414        assert!(container.validate_public_endpoints().is_ok());
1415    }
1416
1417    #[test]
1418    fn test_container_commands_enabled_defaults_false() {
1419        let container = Container::new("no-commands".to_string())
1420            .cluster("compute".to_string())
1421            .code(ContainerCode::Image {
1422                image: "test:latest".to_string(),
1423            })
1424            .cpu(ResourceSpec {
1425                min: "0.5".to_string(),
1426                desired: "1".to_string(),
1427            })
1428            .memory(ResourceSpec {
1429                min: "512Mi".to_string(),
1430                desired: "1Gi".to_string(),
1431            })
1432            .port(8080)
1433            .permissions("test".to_string())
1434            .build();
1435
1436        assert!(!container.commands_enabled);
1437    }
1438
1439    #[test]
1440    fn test_container_commands_enabled_builder() {
1441        let container = Container::new("cmd-container".to_string())
1442            .cluster("compute".to_string())
1443            .code(ContainerCode::Image {
1444                image: "test:latest".to_string(),
1445            })
1446            .cpu(ResourceSpec {
1447                min: "0.5".to_string(),
1448                desired: "1".to_string(),
1449            })
1450            .memory(ResourceSpec {
1451                min: "512Mi".to_string(),
1452                desired: "1Gi".to_string(),
1453            })
1454            .port(8080)
1455            .permissions("test".to_string())
1456            .commands_enabled(true)
1457            .build();
1458
1459        assert!(container.commands_enabled);
1460    }
1461
1462    #[test]
1463    fn test_container_commands_enabled_serializes_camel_case() {
1464        let container = Container::new("cmd-container".to_string())
1465            .cluster("compute".to_string())
1466            .code(ContainerCode::Image {
1467                image: "test:latest".to_string(),
1468            })
1469            .cpu(ResourceSpec {
1470                min: "0.5".to_string(),
1471                desired: "1".to_string(),
1472            })
1473            .memory(ResourceSpec {
1474                min: "512Mi".to_string(),
1475                desired: "1Gi".to_string(),
1476            })
1477            .port(8080)
1478            .permissions("test".to_string())
1479            .commands_enabled(true)
1480            .build();
1481
1482        let json = serde_json::to_value(&container).expect("container should serialize");
1483        assert_eq!(json["commandsEnabled"], true);
1484
1485        let deserialized: Container =
1486            serde_json::from_value(json).expect("container should deserialize");
1487        assert_eq!(deserialized, container);
1488    }
1489}