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
307impl Default for HealthCheck {
308    /// The values a health check takes for every field it leaves out; no port.
309    fn default() -> Self {
310        HealthCheck {
311            path: default_health_path(),
312            port: None,
313            method: default_health_method(),
314            timeout_seconds: default_timeout_seconds(),
315            failure_threshold: default_failure_threshold(),
316        }
317    }
318}
319
320fn default_health_path() -> String {
321    "/health".to_string()
322}
323
324fn default_health_method() -> String {
325    "GET".to_string()
326}
327
328fn default_timeout_seconds() -> u32 {
329    1
330}
331
332fn default_failure_threshold() -> u32 {
333    3
334}
335
336/// Container port configuration.
337#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
338#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
339#[serde(rename_all = "camelCase")]
340pub struct ContainerPort {
341    /// Port number
342    pub port: u16,
343}
344
345/// Outbound network restriction enforced outside the container.
346/// Internet access also permits this container's own replicas on declared TCP ports,
347/// own-service discovery, and the credential proxy for its own identity.
348/// Absence preserves the runtime's existing full-network behavior.
349#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
350#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
351#[serde(rename_all = "camelCase")]
352pub enum ContainerEgress {
353    /// Public addresses and DNS, plus own replicas on declared TCP ports and
354    /// the platform credential proxy for this identity.
355    /// Requires the restricted security profile and backend enforcement.
356    Internet,
357}
358
359/// Container resource for running long-running container workloads.
360///
361/// A Container defines a deployable unit that runs on a ComputeCluster.
362/// The managed container backend handles scheduling replicas across machines,
363/// autoscaling based on various metrics, and service discovery.
364///
365/// ## Example
366///
367/// ```rust
368/// use alien_core::{Container, ContainerCode, ResourceSpec, ContainerAutoscaling, PublicEndpoint, ExposeProtocol};
369///
370/// let container = Container::new("api".to_string())
371///     .cluster("compute".to_string())
372///     .code(ContainerCode::Image {
373///         image: "myapp:latest".to_string(),
374///     })
375///     .cpu(ResourceSpec { min: "0.5".to_string(), desired: "1".to_string() })
376///     .memory(ResourceSpec { min: "512Mi".to_string(), desired: "1Gi".to_string() })
377///     .port(8080)
378///     .public_endpoint(PublicEndpoint {
379///         name: "api".to_string(),
380///         port: 8080,
381///         protocol: ExposeProtocol::Http,
382///         host_label: None,
383///         wildcard_subdomains: false,
384///     })
385///     .autoscaling(ContainerAutoscaling {
386///         min: 2,
387///         desired: 3,
388///         max: 10,
389///         target_cpu_percent: Some(70.0),
390///         target_memory_percent: None,
391///         target_http_in_flight_per_replica: Some(100),
392///         max_http_p95_latency_ms: None,
393///     })
394///     .permissions("container-execution".to_string())
395///     .build();
396/// ```
397#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Builder)]
398#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
399#[serde(rename_all = "camelCase", deny_unknown_fields)]
400#[builder(start_fn = new)]
401pub struct Container {
402    /// Unique identifier for the container.
403    /// Must be DNS-compatible: lowercase alphanumeric with hyphens.
404    #[builder(start_fn)]
405    pub id: String,
406
407    /// Resource links (dependencies)
408    #[builder(field)]
409    pub links: Vec<ResourceRef>,
410
411    /// Ports reachable by compute-cluster members and resources that link to this container.
412    #[builder(field)]
413    pub ports: Vec<ContainerPort>,
414
415    /// Existing Kubernetes Secrets mounted as read-only directories.
416    #[builder(field)]
417    #[serde(default, skip_serializing_if = "Vec::is_empty")]
418    pub kubernetes_secret_mounts: Vec<KubernetesSecretMount>,
419
420    /// Public endpoints exposed by the container.
421    #[builder(field)]
422    #[serde(default, skip_serializing_if = "Vec::is_empty")]
423    pub public_endpoints: Vec<PublicEndpoint>,
424
425    /// Optional public-internet-only egress. Requires restricted security and backend support.
426    #[serde(default, skip_serializing_if = "Option::is_none")]
427    pub egress: Option<ContainerEgress>,
428
429    /// Makes one HTTP port reachable from the control plane through the
430    /// manager's tunnel, over the operator's outbound connection. Nothing is
431    /// exposed on the deployment's network.
432    #[serde(default, skip_serializing_if = "Option::is_none")]
433    pub tunnel: Option<ContainerTunnel>,
434
435    /// Kubernetes liveness probe. Restarts an unhealthy container.
436    #[serde(skip_serializing_if = "Option::is_none")]
437    pub kubernetes_liveness_probe: Option<KubernetesHttpProbe>,
438
439    /// Kubernetes readiness probe. Removes an unready pod from Service endpoints.
440    #[serde(skip_serializing_if = "Option::is_none")]
441    pub kubernetes_readiness_probe: Option<KubernetesHttpProbe>,
442
443    /// Security settings shared by supported container runtimes.
444    #[serde(skip_serializing_if = "Option::is_none")]
445    pub security: Option<ContainerSecurity>,
446
447    /// ComputeCluster resource ID that this container runs on.
448    /// If None, will be auto-assigned by ComputeClusterMutation at deployment time.
449    #[serde(skip_serializing_if = "Option::is_none")]
450    pub cluster: Option<String>,
451
452    /// Container code (image or source)
453    pub code: ContainerCode,
454
455    /// CPU resource requirements
456    pub cpu: ResourceSpec,
457
458    /// Memory resource requirements (must use Ki/Mi/Gi/Ti suffix)
459    pub memory: ResourceSpec,
460
461    /// Optional deployment-time CPU and memory choices. Fixed declarations stay fixed.
462    #[serde(default, skip_serializing_if = "Option::is_none")]
463    pub resource_choices: Option<ContainerResourceChoices>,
464
465    /// GPU requirements (optional)
466    #[serde(skip_serializing_if = "Option::is_none")]
467    pub gpu: Option<ContainerGpuSpec>,
468
469    /// Ephemeral storage requirement (e.g., "10Gi")
470    #[serde(skip_serializing_if = "Option::is_none")]
471    pub ephemeral_storage: Option<String>,
472
473    /// Persistent storage configuration (only for stateful containers)
474    #[serde(skip_serializing_if = "Option::is_none")]
475    pub persistent_storage: Option<PersistentStorage>,
476
477    /// Fixed replica count (for stateful containers or stateless without autoscaling)
478    #[serde(skip_serializing_if = "Option::is_none")]
479    pub replicas: Option<u32>,
480
481    /// Autoscaling configuration (only for stateless containers)
482    #[serde(skip_serializing_if = "Option::is_none")]
483    pub autoscaling: Option<ContainerAutoscaling>,
484
485    /// Whether container is stateful (gets stable ordinals, optional persistent volumes)
486    #[builder(default = false)]
487    #[serde(default)]
488    pub stateful: bool,
489
490    /// Environment variables
491    #[builder(default)]
492    #[serde(default)]
493    pub environment: HashMap<String, String>,
494
495    /// Capacity group to run on (must exist in the cluster)
496    /// If not specified, containers are scheduled to any available group.
497    #[serde(skip_serializing_if = "Option::is_none")]
498    pub pool: Option<String>,
499
500    /// Permission profile name
501    pub permissions: String,
502
503    /// Health check configuration
504    #[serde(skip_serializing_if = "Option::is_none")]
505    pub health_check: Option<HealthCheck>,
506
507    /// Command to override image default
508    #[serde(skip_serializing_if = "Option::is_none")]
509    pub command: Option<Vec<String>>,
510
511    /// Whether the container can receive remote commands via the Commands protocol.
512    /// When enabled, an app-owned command receiver can lease pending commands
513    /// for this Container and execute registered handlers.
514    #[builder(default = default_commands_enabled())]
515    #[serde(default = "default_commands_enabled")]
516    #[cfg_attr(feature = "openapi", schema(default = default_commands_enabled))]
517    pub commands_enabled: bool,
518
519    /// Grace period in seconds for stopping replicas during updates, drains, and deletes.
520    ///
521    /// When omitted, the runtime backend applies its default. Valid values are
522    /// 1 second through 24 hours.
523    #[serde(skip_serializing_if = "Option::is_none")]
524    #[cfg_attr(feature = "openapi", schema(minimum = 1, maximum = 86400))]
525    pub stop_grace_period_seconds: Option<u32>,
526}
527
528impl Container {
529    /// The resource type identifier for Container
530    pub const RESOURCE_TYPE: ResourceType = ResourceType::from_static("container");
531
532    /// Returns the container's unique identifier.
533    pub fn id(&self) -> &str {
534        &self.id
535    }
536
537    /// Returns the permission profile name for this container.
538    pub fn get_permissions(&self) -> &str {
539        &self.permissions
540    }
541
542    /// Returns true if this container is stateless (not stateful).
543    pub fn is_stateless(&self) -> bool {
544        !self.stateful
545    }
546
547    /// Validate resource-local network options; preflight also checks source existence and backend support.
548    pub fn validate_network_settings(&self) -> Result<()> {
549        let invalid = |reason: String| {
550            AlienError::new(ErrorData::InvalidResourceUpdate {
551                resource_id: self.id.clone(),
552                reason,
553            })
554        };
555        if self.egress.is_some()
556            && !self
557                .security
558                .as_ref()
559                .is_some_and(|security| security.profile == ContainerSecurityProfile::Restricted)
560        {
561            return Err(invalid(
562                "container egress internet requires security.profile restricted".into(),
563            ));
564        }
565        Ok(())
566    }
567
568    /// Validates the public endpoint configuration.
569    fn validate_public_endpoints(&self) -> Result<()> {
570        let mut endpoint_names = std::collections::HashSet::new();
571        let mut backend_ports = std::collections::HashSet::new();
572        let mut apex_endpoint_name: Option<&str> = None;
573
574        for endpoint in &self.public_endpoints {
575            endpoint.validate_for_resource(&self.id)?;
576
577            if !endpoint_names.insert(endpoint.name.as_str()) {
578                return Err(AlienError::new(ErrorData::InvalidResourceUpdate {
579                    resource_id: self.id.clone(),
580                    reason: format!("duplicate public endpoint name '{}'", endpoint.name),
581                }));
582            }
583
584            if endpoint.host_label.as_deref() == Some(APEX_HOST_LABEL) {
585                if let Some(existing_name) = apex_endpoint_name {
586                    return Err(AlienError::new(ErrorData::InvalidResourceUpdate {
587                        resource_id: self.id.clone(),
588                        reason: format!(
589                            "only one apex public endpoint is allowed per resource; '{}' already uses hostLabel '@'",
590                            existing_name
591                        ),
592                    }));
593                }
594                apex_endpoint_name = Some(endpoint.name.as_str());
595            }
596
597            backend_ports.insert(endpoint.port);
598
599            if !self.ports.iter().any(|port| port.port == endpoint.port) {
600                return Err(AlienError::new(ErrorData::InvalidResourceUpdate {
601                    resource_id: self.id.clone(),
602                    reason: format!(
603                        "public endpoint '{}' references undeclared port {}",
604                        endpoint.name, endpoint.port
605                    ),
606                }));
607            }
608        }
609
610        if backend_ports.len() > 1 {
611            return Err(AlienError::new(ErrorData::InvalidResourceUpdate {
612                resource_id: self.id.clone(),
613                reason:
614                    "public endpoints on one container must currently route to the same backend port"
615                        .to_string(),
616            }));
617        }
618
619        Ok(())
620    }
621}
622
623fn default_commands_enabled() -> bool {
624    false
625}
626
627impl<S: container_builder::State> ContainerBuilder<S> {
628    /// Links the container to another resource with specified permissions.
629    pub fn link<R: ?Sized>(mut self, resource: &R) -> Self
630    where
631        for<'a> &'a R: Into<ResourceRef>,
632    {
633        let resource_ref: ResourceRef = resource.into();
634        self.links.push(resource_ref);
635        self
636    }
637
638    /// Adds an internal-only port to the container.
639    pub fn port(mut self, port: u16) -> Self {
640        self.ports.push(ContainerPort { port });
641        self
642    }
643
644    /// Mounts an existing Kubernetes Secret without copying its value into the stack.
645    pub fn kubernetes_secret_mount(mut self, mount: KubernetesSecretMount) -> Self {
646        self.kubernetes_secret_mounts.push(mount);
647        self
648    }
649
650    /// Exposes a named public endpoint.
651    pub fn public_endpoint(mut self, endpoint: PublicEndpoint) -> Self {
652        if !self.ports.iter().any(|p| p.port == endpoint.port) {
653            self.ports.push(ContainerPort {
654                port: endpoint.port,
655            });
656        }
657        self.public_endpoints.push(endpoint);
658        self
659    }
660}
661
662/// Container status in the managed container backend.
663#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
664#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
665#[serde(rename_all = "camelCase")]
666pub enum ContainerStatus {
667    /// Waiting for replicas to start
668    Pending,
669    /// Min replicas healthy and serving
670    Running,
671    /// Manually stopped
672    Stopped,
673    /// Something is wrong — see statusReason/statusMessage; scheduler keeps retrying.
674    /// Covers all failure modes: crash-looping, unschedulable, replica failures, etc.
675    Failing,
676}
677
678/// Status of a single container replica.
679#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
680#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
681#[serde(rename_all = "camelCase")]
682pub struct ReplicaStatus {
683    /// Replica ID (e.g., "api-0", "api-1")
684    pub replica_id: String,
685    /// Ordinal (for stateful containers)
686    pub ordinal: Option<u32>,
687    /// Machine ID the replica is running on
688    pub machine_id: Option<String>,
689    /// Whether the replica is healthy
690    pub healthy: bool,
691    /// Container IP address (for service discovery)
692    pub container_ip: Option<String>,
693}
694
695/// A container port reachable from the control plane through the manager.
696///
697/// Requests to `/v1/deployments/{deployment}/tunnels/{container}/...` on the
698/// manager are forwarded to this port over the operator's outbound
699/// connection, so the control plane can call the service without any inbound
700/// network path into the deployment.
701#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
702#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
703#[serde(rename_all = "camelCase", deny_unknown_fields)]
704pub struct ContainerTunnel {
705    /// Container port that serves HTTP. Must be one of the container's ports.
706    pub port: u16,
707}
708
709/// Outputs generated by a successfully provisioned Container.
710#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
711#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
712#[serde(rename_all = "camelCase")]
713pub struct ContainerOutputs {
714    /// Container name in the managed container backend
715    pub name: String,
716    /// Current container status
717    pub status: ContainerStatus,
718    /// Number of current replicas
719    pub current_replicas: u32,
720    /// Desired number of replicas
721    pub desired_replicas: u32,
722    /// Internal DNS name (e.g., "api.svc")
723    pub internal_dns: String,
724    /// Public endpoints resolved for this container.
725    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
726    pub public_endpoints: HashMap<String, PublicEndpointOutput>,
727    /// Internal URLs keyed by port for cluster members and linking resources.
728    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
729    pub internal_urls: HashMap<u16, String>,
730    /// Status of each replica
731    pub replicas: Vec<ReplicaStatus>,
732    /// Persistent volumes, one per replica ordinal, with their latest snapshot.
733    #[serde(default, skip_serializing_if = "Vec::is_empty")]
734    pub volumes: Vec<VolumeOutput>,
735    /// Whether the persistent volumes' snapshot schedule is in place.
736    #[serde(default, skip_serializing_if = "Option::is_none")]
737    pub volume_backups: Option<VolumeBackupsStatus>,
738}
739
740/// Whether a container's snapshot schedule is in place.
741#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
742#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
743#[serde(rename_all = "camelCase")]
744pub struct VolumeBackupsStatus {
745    /// Current state of the schedule
746    pub state: VolumeBackupsState,
747    /// What is missing when the state is `setupRequired`
748    #[serde(skip_serializing_if = "Option::is_none")]
749    pub message: Option<String>,
750}
751
752/// State of a container's snapshot schedule.
753#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
754#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
755#[serde(rename_all = "camelCase")]
756pub enum VolumeBackupsState {
757    /// The cloud's scheduler is taking snapshots.
758    Active,
759    /// Backups are turned off in the stack.
760    Disabled,
761    /// The deployment's management permissions predate volume backups. The
762    /// container keeps running without a schedule until the installation's
763    /// setup is updated; the controller then applies the schedule by itself.
764    SetupRequired,
765}
766
767/// A replica's persistent volume and its latest completed snapshot.
768#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
769#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
770#[serde(rename_all = "camelCase")]
771pub struct VolumeOutput {
772    /// Replica ordinal the volume belongs to
773    pub ordinal: u32,
774    /// Cloud ID of the volume (EBS volume ID, Persistent Disk name, or Managed Disk ID)
775    pub volume_id: String,
776    /// Zone the volume lives in
777    pub zone: String,
778    /// Cloud ID of the latest completed snapshot, if any
779    #[serde(skip_serializing_if = "Option::is_none")]
780    pub last_snapshot_id: Option<String>,
781    /// When the latest completed snapshot was started (RFC 3339)
782    #[serde(skip_serializing_if = "Option::is_none")]
783    pub last_snapshot_at: Option<String>,
784    /// The latest volume restore performed on this ordinal, if any
785    #[serde(skip_serializing_if = "Option::is_none")]
786    pub last_restore: Option<VolumeRestoreOutput>,
787}
788
789/// A completed volume restore.
790#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
791#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
792#[serde(rename_all = "camelCase")]
793pub struct VolumeRestoreOutput {
794    /// ID of the restore request
795    pub request_id: String,
796    /// Snapshot the volume was restored from
797    pub snapshot_id: String,
798    /// Snapshot of the replaced volume, taken just before it was deleted
799    pub replaced_volume_snapshot_id: String,
800    /// When the restored volume was put in place (RFC 3339)
801    pub completed_at: String,
802}
803
804impl ResourceOutputsDefinition for ContainerOutputs {
805    fn get_resource_type(&self) -> ResourceType {
806        Container::RESOURCE_TYPE.clone()
807    }
808
809    fn as_any(&self) -> &dyn Any {
810        self
811    }
812
813    fn box_clone(&self) -> Box<dyn ResourceOutputsDefinition> {
814        Box::new(self.clone())
815    }
816
817    fn outputs_eq(&self, other: &dyn ResourceOutputsDefinition) -> bool {
818        other.as_any().downcast_ref::<ContainerOutputs>() == Some(self)
819    }
820
821    fn to_json_value(&self) -> serde_json::Result<serde_json::Value> {
822        serde_json::to_value(self)
823    }
824}
825
826impl ResourceDefinition for Container {
827    fn get_resource_type(&self) -> ResourceType {
828        Self::RESOURCE_TYPE
829    }
830
831    fn id(&self) -> &str {
832        &self.id
833    }
834
835    fn replace_after_failed_create_is_safe(&self) -> bool {
836        // Deleting it destroys the persistent volumes of a stateful container.
837        !(self.stateful && self.persistent_storage.is_some())
838    }
839
840    fn get_dependencies(&self) -> Vec<ResourceRef> {
841        let mut deps = self.links.clone();
842        if let Some(key) = self
843            .persistent_storage
844            .as_ref()
845            .and_then(|storage| storage.encryption_key.as_ref())
846        {
847            deps.push(key.clone());
848        }
849        // Add dependency on the container cluster if explicitly specified.
850        // If None, ComputeClusterMutation will auto-assign at deployment time.
851        if let Some(cluster) = &self.cluster {
852            deps.push(ResourceRef::new(
853                ComputeCluster::RESOURCE_TYPE.clone(),
854                cluster,
855            ));
856        }
857        deps
858    }
859
860    fn get_permissions(&self) -> Option<&str> {
861        Some(&self.permissions)
862    }
863
864    fn validate_update(&self, new_config: &dyn ResourceDefinition) -> Result<()> {
865        let new_container = new_config
866            .as_any()
867            .downcast_ref::<Container>()
868            .ok_or_else(|| {
869                AlienError::new(ErrorData::UnexpectedResourceType {
870                    resource_id: self.id.clone(),
871                    expected: Self::RESOURCE_TYPE,
872                    actual: new_config.get_resource_type(),
873                })
874            })?;
875
876        // Validate the new config's public endpoints.
877        new_container.validate_public_endpoints()?;
878        new_container.validate_network_settings()?;
879
880        if self.id != new_container.id {
881            return Err(AlienError::new(ErrorData::InvalidResourceUpdate {
882                resource_id: self.id.clone(),
883                reason: "the 'id' field is immutable".to_string(),
884            }));
885        }
886
887        // Cluster is immutable
888        if self.cluster != new_container.cluster {
889            return Err(AlienError::new(ErrorData::InvalidResourceUpdate {
890                resource_id: self.id.clone(),
891                reason: "the 'cluster' field is immutable".to_string(),
892            }));
893        }
894
895        // Stateful is immutable
896        if self.stateful != new_container.stateful {
897            return Err(AlienError::new(ErrorData::InvalidResourceUpdate {
898                resource_id: self.id.clone(),
899                reason: "the 'stateful' field is immutable".to_string(),
900            }));
901        }
902
903        let old_key = self
904            .persistent_storage
905            .as_ref()
906            .and_then(|storage| storage.encryption_key.as_ref());
907        let new_key = new_container
908            .persistent_storage
909            .as_ref()
910            .and_then(|storage| storage.encryption_key.as_ref());
911        if old_key != new_key {
912            return Err(AlienError::new(ErrorData::InvalidResourceUpdate {
913                resource_id: self.id.clone(),
914                reason: "the 'persistentStorage.encryptionKey' field is immutable once the resource exists".to_string(),
915            }));
916        }
917
918        // Ports are immutable (requires load balancer reconfiguration)
919        if self.ports != new_container.ports {
920            return Err(AlienError::new(ErrorData::InvalidResourceUpdate {
921                resource_id: self.id.clone(),
922                reason: "the 'ports' field is immutable".to_string(),
923            }));
924        }
925
926        if self.public_endpoints != new_container.public_endpoints {
927            return Err(AlienError::new(ErrorData::InvalidResourceUpdate {
928                resource_id: self.id.clone(),
929                reason: "the 'publicEndpoints' field is immutable".to_string(),
930            }));
931        }
932
933        // Pool (capacity group) is immutable
934        if self.pool != new_container.pool {
935            return Err(AlienError::new(ErrorData::InvalidResourceUpdate {
936                resource_id: self.id.clone(),
937                reason: "the 'pool' field is immutable".to_string(),
938            }));
939        }
940
941        Ok(())
942    }
943
944    fn as_any(&self) -> &dyn Any {
945        self
946    }
947
948    fn as_any_mut(&mut self) -> &mut dyn Any {
949        self
950    }
951
952    fn box_clone(&self) -> Box<dyn ResourceDefinition> {
953        Box::new(self.clone())
954    }
955
956    fn resource_eq(&self, other: &dyn ResourceDefinition) -> bool {
957        other.as_any().downcast_ref::<Container>() == Some(self)
958    }
959
960    fn to_json_value(&self) -> serde_json::Result<serde_json::Value> {
961        serde_json::to_value(self)
962    }
963}
964
965#[cfg(test)]
966mod tests {
967    use super::*;
968    use crate::resources::ExposeProtocol;
969
970    fn network_container() -> Container {
971        Container::new("api".to_string())
972            .code(ContainerCode::Image {
973                image: "example:latest".into(),
974            })
975            .cpu(ResourceSpec {
976                min: "1".into(),
977                desired: "1".into(),
978            })
979            .memory(ResourceSpec {
980                min: "512Mi".into(),
981                desired: "512Mi".into(),
982            })
983            .permissions("execution".into())
984            .public_endpoint(PublicEndpoint {
985                name: "web".into(),
986                port: 8080,
987                protocol: ExposeProtocol::Http,
988                host_label: None,
989                wildcard_subdomains: false,
990            })
991            .build()
992    }
993
994    #[test]
995    fn internet_egress_requires_restricted_security_and_is_opt_in() {
996        let mut container = network_container();
997        assert!(serde_json::to_value(&container)
998            .unwrap()
999            .get("egress")
1000            .is_none());
1001        container.egress = Some(ContainerEgress::Internet);
1002        assert!(container.validate_update(&container).is_err());
1003        container.security = Some(ContainerSecurity {
1004            profile: ContainerSecurityProfile::Restricted,
1005            run_as_user: 65532,
1006            run_as_group: 65532,
1007            read_only_root_filesystem: true,
1008        });
1009        container.validate_update(&container).unwrap();
1010        assert_eq!(
1011            serde_json::to_value(&container).unwrap()["egress"],
1012            "internet"
1013        );
1014    }
1015
1016    #[test]
1017    fn test_container_creation_with_autoscaling() {
1018        let container = Container::new("api".to_string())
1019            .cluster("compute".to_string())
1020            .code(ContainerCode::Image {
1021                image: "myapp:latest".to_string(),
1022            })
1023            .cpu(ResourceSpec {
1024                min: "0.5".to_string(),
1025                desired: "1".to_string(),
1026            })
1027            .memory(ResourceSpec {
1028                min: "512Mi".to_string(),
1029                desired: "1Gi".to_string(),
1030            })
1031            .port(8080)
1032            .public_endpoint(PublicEndpoint {
1033                name: "api".to_string(),
1034                port: 8080,
1035                protocol: ExposeProtocol::Http,
1036                host_label: None,
1037                wildcard_subdomains: false,
1038            })
1039            .autoscaling(ContainerAutoscaling {
1040                min: 2,
1041                desired: 3,
1042                max: 10,
1043                target_cpu_percent: Some(70.0),
1044                target_memory_percent: None,
1045                target_http_in_flight_per_replica: Some(100),
1046                max_http_p95_latency_ms: None,
1047            })
1048            .permissions("container-execution".to_string())
1049            .build();
1050
1051        assert_eq!(container.id(), "api");
1052        assert_eq!(container.cluster, Some("compute".to_string()));
1053        assert!(!container.stateful);
1054        assert!(container.autoscaling.is_some());
1055        assert_eq!(container.ports.len(), 1);
1056        assert_eq!(container.ports[0].port, 8080);
1057    }
1058
1059    #[test]
1060    fn container_serializes_stop_grace_period_when_set() {
1061        let container = Container::new("api".to_string())
1062            .cluster("compute".to_string())
1063            .code(ContainerCode::Image {
1064                image: "myapp:latest".to_string(),
1065            })
1066            .cpu(ResourceSpec {
1067                min: "0.5".to_string(),
1068                desired: "1".to_string(),
1069            })
1070            .memory(ResourceSpec {
1071                min: "512Mi".to_string(),
1072                desired: "1Gi".to_string(),
1073            })
1074            .port(8080)
1075            .permissions("container-execution".to_string())
1076            .stop_grace_period_seconds(21_600)
1077            .build();
1078
1079        let json = serde_json::to_value(&container).expect("container should serialize");
1080        assert_eq!(json["stopGracePeriodSeconds"], 21_600);
1081    }
1082
1083    #[test]
1084    fn container_omits_stop_grace_period_when_absent() {
1085        let container = Container::new("api".to_string())
1086            .cluster("compute".to_string())
1087            .code(ContainerCode::Image {
1088                image: "myapp:latest".to_string(),
1089            })
1090            .cpu(ResourceSpec {
1091                min: "0.5".to_string(),
1092                desired: "1".to_string(),
1093            })
1094            .memory(ResourceSpec {
1095                min: "512Mi".to_string(),
1096                desired: "1Gi".to_string(),
1097            })
1098            .port(8080)
1099            .permissions("container-execution".to_string())
1100            .build();
1101
1102        let json = serde_json::to_value(&container).expect("container should serialize");
1103        assert!(json.get("stopGracePeriodSeconds").is_none());
1104    }
1105
1106    #[test]
1107    fn test_stateful_container_with_storage() {
1108        let container = Container::new("postgres".to_string())
1109            .cluster("compute".to_string())
1110            .code(ContainerCode::Image {
1111                image: "postgres:16".to_string(),
1112            })
1113            .cpu(ResourceSpec {
1114                min: "1".to_string(),
1115                desired: "2".to_string(),
1116            })
1117            .memory(ResourceSpec {
1118                min: "2Gi".to_string(),
1119                desired: "4Gi".to_string(),
1120            })
1121            .port(5432)
1122            .stateful(true)
1123            .replicas(1)
1124            .persistent_storage(PersistentStorage {
1125                encryption_key: None,
1126                size: "100Gi".to_string(),
1127                mount_path: "/var/lib/postgresql/data".to_string(),
1128                backups: VolumeBackups::default(),
1129            })
1130            .permissions("database".to_string())
1131            .build();
1132
1133        assert_eq!(container.id(), "postgres");
1134        assert!(container.stateful);
1135        assert!(container.replicas.is_some());
1136        assert!(container.persistent_storage.is_some());
1137    }
1138
1139    #[test]
1140    fn test_public_container() {
1141        let container = Container::new("frontend".to_string())
1142            .cluster("compute".to_string())
1143            .code(ContainerCode::Image {
1144                image: "frontend:latest".to_string(),
1145            })
1146            .cpu(ResourceSpec {
1147                min: "0.25".to_string(),
1148                desired: "0.5".to_string(),
1149            })
1150            .memory(ResourceSpec {
1151                min: "256Mi".to_string(),
1152                desired: "512Mi".to_string(),
1153            })
1154            .port(3000)
1155            .public_endpoint(PublicEndpoint {
1156                name: "web".to_string(),
1157                port: 3000,
1158                protocol: ExposeProtocol::Http,
1159                host_label: None,
1160                wildcard_subdomains: false,
1161            })
1162            .autoscaling(ContainerAutoscaling {
1163                min: 2,
1164                desired: 2,
1165                max: 20,
1166                target_cpu_percent: None,
1167                target_memory_percent: None,
1168                target_http_in_flight_per_replica: Some(50),
1169                max_http_p95_latency_ms: Some(100.0),
1170            })
1171            .health_check(HealthCheck {
1172                path: "/health".to_string(),
1173                port: None,
1174                method: "GET".to_string(),
1175                timeout_seconds: 1,
1176                failure_threshold: 3,
1177            })
1178            .permissions("frontend".to_string())
1179            .build();
1180
1181        assert_eq!(container.ports[0].port, 3000);
1182        assert_eq!(container.public_endpoints[0].name, "web");
1183        assert!(container.health_check.is_some());
1184    }
1185
1186    #[test]
1187    fn test_public_container_endpoint_options() {
1188        let container = Container::new("router".to_string())
1189            .cluster("compute".to_string())
1190            .code(ContainerCode::Image {
1191                image: "router:latest".to_string(),
1192            })
1193            .cpu(ResourceSpec {
1194                min: "0.25".to_string(),
1195                desired: "0.5".to_string(),
1196            })
1197            .memory(ResourceSpec {
1198                min: "256Mi".to_string(),
1199                desired: "512Mi".to_string(),
1200            })
1201            .public_endpoint(PublicEndpoint {
1202                name: "gateway".to_string(),
1203                port: 8080,
1204                protocol: ExposeProtocol::Http,
1205                host_label: Some("gateway".to_string()),
1206                wildcard_subdomains: true,
1207            })
1208            .permissions("router".to_string())
1209            .build();
1210
1211        assert!(container.validate_public_endpoints().is_ok());
1212        assert_eq!(container.ports.len(), 1);
1213        assert_eq!(container.public_endpoints.len(), 1);
1214        assert_eq!(
1215            container.public_endpoints[0].host_label.as_deref(),
1216            Some("gateway")
1217        );
1218        assert!(container.public_endpoints[0].wildcard_subdomains);
1219    }
1220
1221    #[test]
1222    fn test_public_container_rejects_invalid_host_label() {
1223        let container = Container::new("router".to_string())
1224            .cluster("compute".to_string())
1225            .code(ContainerCode::Image {
1226                image: "router:latest".to_string(),
1227            })
1228            .cpu(ResourceSpec {
1229                min: "0.25".to_string(),
1230                desired: "0.5".to_string(),
1231            })
1232            .memory(ResourceSpec {
1233                min: "256Mi".to_string(),
1234                desired: "512Mi".to_string(),
1235            })
1236            .public_endpoint(PublicEndpoint {
1237                name: "gateway".to_string(),
1238                port: 8080,
1239                protocol: ExposeProtocol::Http,
1240                host_label: Some("bad.label".to_string()),
1241                wildcard_subdomains: true,
1242            })
1243            .permissions("router".to_string())
1244            .build();
1245
1246        assert!(container.validate_public_endpoints().is_err());
1247    }
1248
1249    #[test]
1250    fn test_container_with_links() {
1251        use crate::Storage;
1252
1253        let storage = Storage::new("data".to_string()).build();
1254
1255        let container = Container::new("worker".to_string())
1256            .cluster("compute".to_string())
1257            .code(ContainerCode::Image {
1258                image: "worker:latest".to_string(),
1259            })
1260            .cpu(ResourceSpec {
1261                min: "0.5".to_string(),
1262                desired: "1".to_string(),
1263            })
1264            .memory(ResourceSpec {
1265                min: "512Mi".to_string(),
1266                desired: "1Gi".to_string(),
1267            })
1268            .port(8080)
1269            .replicas(3)
1270            .link(&storage)
1271            .permissions("worker".to_string())
1272            .build();
1273
1274        // Should have 2 dependencies: cluster + linked storage
1275        let deps = container.get_dependencies();
1276        assert_eq!(deps.len(), 2);
1277    }
1278
1279    #[test]
1280    fn test_container_validate_update_immutable_cluster() {
1281        let container1 = Container::new("api".to_string())
1282            .cluster("cluster-1".to_string())
1283            .code(ContainerCode::Image {
1284                image: "myapp:v1".to_string(),
1285            })
1286            .cpu(ResourceSpec {
1287                min: "0.5".to_string(),
1288                desired: "1".to_string(),
1289            })
1290            .memory(ResourceSpec {
1291                min: "512Mi".to_string(),
1292                desired: "1Gi".to_string(),
1293            })
1294            .port(8080)
1295            .replicas(2)
1296            .permissions("execution".to_string())
1297            .build();
1298
1299        let container2 = Container::new("api".to_string())
1300            .cluster("cluster-2".to_string()) // Changed cluster
1301            .code(ContainerCode::Image {
1302                image: "myapp:v2".to_string(),
1303            })
1304            .cpu(ResourceSpec {
1305                min: "0.5".to_string(),
1306                desired: "1".to_string(),
1307            })
1308            .memory(ResourceSpec {
1309                min: "512Mi".to_string(),
1310                desired: "1Gi".to_string(),
1311            })
1312            .port(8080)
1313            .replicas(2)
1314            .permissions("execution".to_string())
1315            .build();
1316
1317        let result = container1.validate_update(&container2);
1318        assert!(result.is_err());
1319    }
1320
1321    #[test]
1322    fn test_container_validate_update_allowed_changes() {
1323        let container1 = Container::new("api".to_string())
1324            .cluster("compute".to_string())
1325            .code(ContainerCode::Image {
1326                image: "myapp:v1".to_string(),
1327            })
1328            .cpu(ResourceSpec {
1329                min: "0.5".to_string(),
1330                desired: "1".to_string(),
1331            })
1332            .memory(ResourceSpec {
1333                min: "512Mi".to_string(),
1334                desired: "1Gi".to_string(),
1335            })
1336            .port(8080)
1337            .replicas(2)
1338            .permissions("execution".to_string())
1339            .build();
1340
1341        let container2 = Container::new("api".to_string())
1342            .cluster("compute".to_string())
1343            .code(ContainerCode::Image {
1344                image: "myapp:v2".to_string(), // Image can change
1345            })
1346            .cpu(ResourceSpec {
1347                min: "1".to_string(), // Resources can change
1348                desired: "2".to_string(),
1349            })
1350            .memory(ResourceSpec {
1351                min: "1Gi".to_string(),
1352                desired: "2Gi".to_string(),
1353            })
1354            .port(8080)
1355            .replicas(5) // Replicas can change
1356            .permissions("execution".to_string())
1357            .build();
1358
1359        let result = container1.validate_update(&container2);
1360        assert!(result.is_ok());
1361    }
1362
1363    #[test]
1364    fn test_container_serialization() {
1365        let container = Container::new("test".to_string())
1366            .cluster("compute".to_string())
1367            .code(ContainerCode::Image {
1368                image: "test:latest".to_string(),
1369            })
1370            .cpu(ResourceSpec {
1371                min: "0.5".to_string(),
1372                desired: "1".to_string(),
1373            })
1374            .memory(ResourceSpec {
1375                min: "512Mi".to_string(),
1376                desired: "1Gi".to_string(),
1377            })
1378            .port(8080)
1379            .replicas(1)
1380            .permissions("test".to_string())
1381            .build();
1382
1383        let json = serde_json::to_string(&container).unwrap();
1384        let deserialized: Container = serde_json::from_str(&json).unwrap();
1385        assert_eq!(container, deserialized);
1386    }
1387
1388    #[test]
1389    fn test_container_multi_endpoint_validation() {
1390        let container = Container::new("multi-tcp".to_string())
1391            .cluster("compute".to_string())
1392            .code(ContainerCode::Image {
1393                image: "test:latest".to_string(),
1394            })
1395            .cpu(ResourceSpec {
1396                min: "1".to_string(),
1397                desired: "1".to_string(),
1398            })
1399            .memory(ResourceSpec {
1400                min: "1Gi".to_string(),
1401                desired: "1Gi".to_string(),
1402            })
1403            .port(8080)
1404            .public_endpoint(PublicEndpoint {
1405                name: "api".to_string(),
1406                port: 8080,
1407                protocol: ExposeProtocol::Http,
1408                host_label: None,
1409                wildcard_subdomains: false,
1410            })
1411            .public_endpoint(PublicEndpoint {
1412                name: "wildcard".to_string(),
1413                port: 8080,
1414                protocol: ExposeProtocol::Http,
1415                host_label: Some("wildcard".to_string()),
1416                wildcard_subdomains: true,
1417            })
1418            .replicas(1)
1419            .permissions("test".to_string())
1420            .build();
1421
1422        assert!(container.validate_public_endpoints().is_ok());
1423
1424        let invalid_container = Container::new("multi-http".to_string())
1425            .cluster("compute".to_string())
1426            .code(ContainerCode::Image {
1427                image: "test:latest".to_string(),
1428            })
1429            .cpu(ResourceSpec {
1430                min: "1".to_string(),
1431                desired: "1".to_string(),
1432            })
1433            .memory(ResourceSpec {
1434                min: "1Gi".to_string(),
1435                desired: "1Gi".to_string(),
1436            })
1437            .port(8080)
1438            .port(9090)
1439            .public_endpoint(PublicEndpoint {
1440                name: "api".to_string(),
1441                port: 8080,
1442                protocol: ExposeProtocol::Http,
1443                host_label: None,
1444                wildcard_subdomains: false,
1445            })
1446            .public_endpoint(PublicEndpoint {
1447                name: "admin".to_string(),
1448                port: 9090,
1449                protocol: ExposeProtocol::Http,
1450                host_label: None,
1451                wildcard_subdomains: false,
1452            })
1453            .replicas(1)
1454            .permissions("test".to_string())
1455            .build();
1456
1457        assert!(invalid_container.validate_public_endpoints().is_err());
1458    }
1459
1460    #[test]
1461    fn container_rejects_multiple_apex_public_endpoints() {
1462        let container = Container::new("apex-container".to_string())
1463            .cluster("compute".to_string())
1464            .code(ContainerCode::Image {
1465                image: "test:latest".to_string(),
1466            })
1467            .cpu(ResourceSpec {
1468                min: "1".to_string(),
1469                desired: "1".to_string(),
1470            })
1471            .memory(ResourceSpec {
1472                min: "1Gi".to_string(),
1473                desired: "1Gi".to_string(),
1474            })
1475            .port(8080)
1476            .public_endpoint(PublicEndpoint {
1477                name: "web".to_string(),
1478                port: 8080,
1479                protocol: ExposeProtocol::Http,
1480                host_label: Some(APEX_HOST_LABEL.to_string()),
1481                wildcard_subdomains: false,
1482            })
1483            .public_endpoint(PublicEndpoint {
1484                name: "admin".to_string(),
1485                port: 8080,
1486                protocol: ExposeProtocol::Http,
1487                host_label: Some(APEX_HOST_LABEL.to_string()),
1488                wildcard_subdomains: false,
1489            })
1490            .replicas(1)
1491            .permissions("test".to_string())
1492            .build();
1493
1494        assert!(container.validate_public_endpoints().is_err());
1495    }
1496
1497    #[test]
1498    fn test_container_empty_ports_validation() {
1499        let container = Container::new("no-ports".to_string())
1500            .cluster("compute".to_string())
1501            .code(ContainerCode::Image {
1502                image: "test:latest".to_string(),
1503            })
1504            .cpu(ResourceSpec {
1505                min: "1".to_string(),
1506                desired: "1".to_string(),
1507            })
1508            .memory(ResourceSpec {
1509                min: "1Gi".to_string(),
1510                desired: "1Gi".to_string(),
1511            })
1512            .replicas(1)
1513            .permissions("test".to_string())
1514            .build();
1515
1516        assert!(container.validate_public_endpoints().is_ok());
1517    }
1518
1519    #[test]
1520    fn test_container_commands_enabled_defaults_false() {
1521        let container = Container::new("no-commands".to_string())
1522            .cluster("compute".to_string())
1523            .code(ContainerCode::Image {
1524                image: "test:latest".to_string(),
1525            })
1526            .cpu(ResourceSpec {
1527                min: "0.5".to_string(),
1528                desired: "1".to_string(),
1529            })
1530            .memory(ResourceSpec {
1531                min: "512Mi".to_string(),
1532                desired: "1Gi".to_string(),
1533            })
1534            .port(8080)
1535            .permissions("test".to_string())
1536            .build();
1537
1538        assert!(!container.commands_enabled);
1539    }
1540
1541    #[test]
1542    fn test_container_commands_enabled_builder() {
1543        let container = Container::new("cmd-container".to_string())
1544            .cluster("compute".to_string())
1545            .code(ContainerCode::Image {
1546                image: "test:latest".to_string(),
1547            })
1548            .cpu(ResourceSpec {
1549                min: "0.5".to_string(),
1550                desired: "1".to_string(),
1551            })
1552            .memory(ResourceSpec {
1553                min: "512Mi".to_string(),
1554                desired: "1Gi".to_string(),
1555            })
1556            .port(8080)
1557            .permissions("test".to_string())
1558            .commands_enabled(true)
1559            .build();
1560
1561        assert!(container.commands_enabled);
1562    }
1563
1564    #[test]
1565    fn test_container_commands_enabled_serializes_camel_case() {
1566        let container = Container::new("cmd-container".to_string())
1567            .cluster("compute".to_string())
1568            .code(ContainerCode::Image {
1569                image: "test:latest".to_string(),
1570            })
1571            .cpu(ResourceSpec {
1572                min: "0.5".to_string(),
1573                desired: "1".to_string(),
1574            })
1575            .memory(ResourceSpec {
1576                min: "512Mi".to_string(),
1577                desired: "1Gi".to_string(),
1578            })
1579            .port(8080)
1580            .permissions("test".to_string())
1581            .commands_enabled(true)
1582            .build();
1583
1584        let json = serde_json::to_value(&container).expect("container should serialize");
1585        assert_eq!(json["commandsEnabled"], true);
1586
1587        let deserialized: Container =
1588            serde_json::from_value(json).expect("container should deserialize");
1589        assert_eq!(deserialized, container);
1590    }
1591}