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