Skip to main content

alien_core/resources/
container.rs

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