Skip to main content

alien_core/resources/
container.rs

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