Skip to main content

alien_core/
stack_settings.rs

1//!
2//! Defines stack-level settings and management configurations for different cloud platforms.
3//! These settings customize deployment behavior and cross-account/cross-tenant access patterns.
4
5use serde::{Deserialize, Serialize};
6use std::collections::HashMap;
7
8use crate::{KubernetesCloudReference, KubernetesClusterOwnership};
9
10/// AWS management configuration extracted from stack settings
11#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
12#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
13#[serde(rename_all = "camelCase")]
14pub struct AwsManagementConfig {
15    /// The managing AWS IAM role ARN that can assume cross-account roles
16    pub managing_role_arn: String,
17}
18
19/// GCP management configuration extracted from stack settings
20#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
21#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
22#[serde(rename_all = "camelCase")]
23pub struct GcpManagementConfig {
24    /// Linked source account metadata; never a direct target trust grant.
25    pub service_account_email: String,
26    /// Available on managers supporting customer-owned target federation.
27    /// New GCP setup requires this capability; unaffected cloud metadata stays readable.
28    #[serde(default, skip_serializing_if = "Option::is_none")]
29    pub federation_trust: Option<crate::GcpFederationTrust>,
30}
31
32/// Azure management configuration extracted from stack settings
33#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
34#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
35#[serde(rename_all = "camelCase")]
36pub struct AzureManagementConfig {
37    /// The managing Azure Tenant ID for cross-tenant access
38    pub managing_tenant_id: String,
39    /// OIDC issuer URL trusted by the target-side managed identity.
40    pub oidc_issuer: String,
41    /// OIDC subject claim trusted by the target-side managed identity.
42    pub oidc_subject: String,
43}
44
45/// Management configuration for different cloud platforms.
46///
47/// Platform-derived configuration for cross-account/cross-tenant access.
48/// This is NOT user-specified - it's derived from the Manager's ServiceAccount.
49#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
50#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
51#[serde(rename_all = "camelCase", tag = "platform")]
52pub enum ManagementConfig {
53    /// AWS management configuration
54    Aws(AwsManagementConfig),
55    /// GCP management configuration  
56    Gcp(GcpManagementConfig),
57    /// Azure management configuration
58    Azure(AzureManagementConfig),
59    /// Kubernetes management configuration (minimal for now)
60    Kubernetes,
61}
62
63/// Network configuration for the stack.
64///
65/// Controls how VPC/VNet networking is provisioned. Users configure this in
66/// `StackSettings`; the Network resource itself is auto-generated by preflights.
67///
68/// ## Egress policy
69///
70/// Container cluster VMs are configured for egress based on the mode:
71///
72/// - `UseDefault` → VMs get ephemeral public IPs (no NAT is provisioned)
73/// - `Create` → VMs use private IPs; Alien provisions a NAT gateway for outbound access
74/// - `ByoVpc*` / `ByoVnet*` → no public IPs assigned; customer manages egress
75///
76/// For production workloads, use `Create`. For fast dev/test iteration, `UseDefault` is
77/// sufficient. For environments with existing VPCs, use the appropriate `ByoVpc*` variant.
78#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
79#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
80#[serde(rename_all = "camelCase", tag = "type")]
81pub enum NetworkSettings {
82    /// Use the cloud provider's default VPC/network.
83    ///
84    /// Designed for fast dev/test provisioning. No isolated VPC is created, so there
85    /// is nothing to wait for or clean up. VMs receive ephemeral public IPs for internet
86    /// access — no NAT gateway is provisioned.
87    ///
88    /// - **AWS**: Discovers the account's default VPC. Subnets are public with auto-assigned IPs.
89    /// - **GCP**: Discovers the project's `default` network and regional subnet. Instance
90    ///   templates include an `AccessConfig` to assign an ephemeral external IP.
91    /// - **Azure**: Azure has no default VNet, so one is created along with a NAT Gateway.
92    ///   VMs stay private and use NAT for egress.
93    ///
94    /// Not recommended for production. Use `Create` instead.
95    #[serde(rename = "use-default")]
96    UseDefault,
97
98    /// Create a new isolated VPC/VNet with a managed NAT gateway.
99    ///
100    /// All networking infrastructure is provisioned by Alien and cleaned up on delete.
101    /// VMs use private IPs only; all outbound traffic routes through the NAT gateway.
102    ///
103    /// Recommended for production deployments.
104    #[serde(rename = "create")]
105    Create {
106        /// VPC/VNet CIDR block. If not specified, auto-generated from stack ID
107        /// to reduce conflicts (e.g., "10.{hash}.0.0/16").
108        #[serde(skip_serializing_if = "Option::is_none")]
109        cidr: Option<String>,
110
111        /// Number of availability zones (default: 2).
112        #[serde(default = "default_availability_zones")]
113        availability_zones: u8,
114    },
115
116    /// Use an existing VPC (AWS).
117    ///
118    /// Alien validates the references but creates no networking infrastructure.
119    /// The customer is responsible for routing and egress (NAT, proxy, VPN, etc.).
120    #[serde(rename = "byo-vpc-aws")]
121    ByoVpcAws {
122        /// The ID of the existing VPC
123        vpc_id: String,
124        /// IDs of public subnets (required for public ingress)
125        #[serde(deserialize_with = "crate::network_ids::deserialize_ids")]
126        public_subnet_ids: Vec<String>,
127        /// IDs of private subnets
128        #[serde(deserialize_with = "crate::network_ids::deserialize_ids")]
129        private_subnet_ids: Vec<String>,
130        /// Optional security group IDs to use
131        #[serde(default)]
132        #[serde(deserialize_with = "crate::network_ids::deserialize_ids")]
133        security_group_ids: Vec<String>,
134    },
135
136    /// Use an existing VPC (GCP).
137    ///
138    /// Alien validates the references but creates no networking infrastructure.
139    /// The customer is responsible for routing and egress (Cloud NAT, proxy, VPN, etc.).
140    #[serde(rename = "byo-vpc-gcp")]
141    ByoVpcGcp {
142        /// The name of the existing VPC network
143        network_name: String,
144        /// The name of the subnet to use
145        subnet_name: String,
146        /// The region of the subnet
147        region: String,
148    },
149
150    /// Use an existing VNet (Azure).
151    ///
152    /// Alien validates the references but creates no networking infrastructure.
153    /// The customer is responsible for routing and egress (NAT Gateway, proxy, VPN, etc.).
154    #[serde(rename = "byo-vnet-azure")]
155    ByoVnetAzure {
156        /// The full resource ID of the existing VNet
157        vnet_resource_id: String,
158        /// Name of the public subnet within the VNet
159        public_subnet_name: String,
160        /// Name of the private subnet within the VNet
161        private_subnet_name: String,
162        /// Name of the dedicated classic Application Gateway subnet within the VNet.
163        #[serde(default, skip_serializing_if = "Option::is_none")]
164        application_gateway_subnet_name: Option<String>,
165        /// Name of the dedicated subnet that hosts Private Endpoints (e.g. for a
166        /// Postgres Flexible Server). A Private Endpoint must not share the private
167        /// subnet, which is already claimed by the Container Apps environment's
168        /// `infrastructure_subnet_id`. Required only when the stack contains a
169        /// Postgres resource; otherwise unused.
170        #[serde(default, skip_serializing_if = "Option::is_none")]
171        private_endpoint_subnet_name: Option<String>,
172    },
173}
174
175fn default_availability_zones() -> u8 {
176    2
177}
178
179/// Deployment-time compute choices for Alien-managed compute pools.
180///
181/// Application source declares portable pool requirements. This settings
182/// object stores the concrete choices made for one deployment, such as the
183/// provider machine type and selected machine counts.
184#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
185#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
186#[serde(rename_all = "camelCase")]
187pub struct ComputeSettings {
188    /// Selected compute choices keyed by pool ID.
189    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
190    pub pools: HashMap<String, ComputePoolSelection>,
191    /// Per-replica resources selected within each container's declared ranges.
192    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
193    pub containers: HashMap<String, ContainerResourceSelection>,
194}
195
196/// Deployment-time resource allocation for a container. Omitted fields use release defaults.
197#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
198#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
199#[serde(rename_all = "camelCase", deny_unknown_fields)]
200pub struct ContainerResourceSelection {
201    /// CPU allocation in vCPUs.
202    #[serde(
203        default,
204        skip_serializing_if = "Option::is_none",
205        deserialize_with = "deserialize_selected_cpu"
206    )]
207    #[cfg_attr(feature = "openapi", schema(value_type = Option<f64>))]
208    pub cpu: Option<serde_json::Number>,
209    /// Memory allocation, using binary units such as Mi or Gi.
210    #[serde(default, skip_serializing_if = "Option::is_none")]
211    pub memory: Option<String>,
212    /// Persistent volume capacity per replica, in whole GiB. Omitted values
213    /// use the release default. Existing volumes can only grow.
214    #[serde(default, skip_serializing_if = "Option::is_none")]
215    #[serde(deserialize_with = "deserialize_selected_volume_size")]
216    pub volume_size_gib: Option<u32>,
217}
218
219fn deserialize_selected_volume_size<'de, D: serde::Deserializer<'de>>(
220    deserializer: D,
221) -> std::result::Result<Option<u32>, D::Error> {
222    // CloudFormation Number references arrive as strings in setup import JSON.
223    match Option::<serde_json::Value>::deserialize(deserializer)? {
224        None => Ok(None),
225        Some(serde_json::Value::Number(number)) => number
226            .as_u64()
227            .and_then(|number| u32::try_from(number).ok())
228            .map(Some)
229            .ok_or_else(|| {
230                serde::de::Error::custom("volumeSizeGib must be a whole positive capacity")
231            }),
232        Some(serde_json::Value::String(number)) => {
233            number.parse().map(Some).map_err(serde::de::Error::custom)
234        }
235        Some(_) => Err(serde::de::Error::custom(
236            "volumeSizeGib must be a whole positive capacity",
237        )),
238    }
239}
240
241fn deserialize_selected_cpu<'de, D: serde::Deserializer<'de>>(
242    deserializer: D,
243) -> std::result::Result<Option<serde_json::Number>, D::Error> {
244    // CloudFormation returns Number parameter references as strings.
245    match Option::<serde_json::Value>::deserialize(deserializer)? {
246        None => Ok(None),
247        Some(serde_json::Value::Number(number)) => Ok(Some(number)),
248        Some(serde_json::Value::String(number)) => {
249            number.parse().map(Some).map_err(serde::de::Error::custom)
250        }
251        Some(_) => Err(serde::de::Error::custom("CPU must be a number")),
252    }
253}
254
255/// Failure-domain policy selected for a compute pool.
256#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
257#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
258#[serde(rename_all = "camelCase")]
259pub struct FailureDomainSelection {
260    /// Number of distinct failure domains across which new stateful replicas may be spread.
261    pub spread: u8,
262    /// Concrete provider domains selected during setup.
263    /// Empty delegates deterministic selection to the provider setup implementation.
264    #[serde(default, skip_serializing_if = "Vec::is_empty")]
265    pub selected_failure_domains: Vec<String>,
266}
267
268/// User-selected deployment settings for one compute pool.
269#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
270#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
271#[serde(rename_all = "camelCase", tag = "mode")]
272pub enum ComputePoolSelection {
273    /// Fixed number of machines.
274    Fixed {
275        /// Number of machines to run.
276        machines: u32,
277        /// Provider machine type selected for this deployment.
278        #[serde(
279            default,
280            skip_serializing_if = "Option::is_none",
281            deserialize_with = "deserialize_machine_override"
282        )]
283        machine: Option<String>,
284        /// Optional failure-domain policy. Absence preserves the existing aggregate layout.
285        #[serde(default, skip_serializing_if = "Option::is_none")]
286        failure_domains: Option<FailureDomainSelection>,
287    },
288    /// Autoscaling machine pool.
289    Autoscale {
290        /// Minimum machine count.
291        min: u32,
292        /// Maximum machine count.
293        max: u32,
294        /// Provider machine type selected for this deployment.
295        #[serde(
296            default,
297            skip_serializing_if = "Option::is_none",
298            deserialize_with = "deserialize_machine_override"
299        )]
300        machine: Option<String>,
301        /// Optional failure-domain policy. Absence preserves the existing aggregate layout.
302        #[serde(default, skip_serializing_if = "Option::is_none")]
303        failure_domains: Option<FailureDomainSelection>,
304    },
305}
306
307fn deserialize_machine_override<'de, D: serde::Deserializer<'de>>(
308    deserializer: D,
309) -> std::result::Result<Option<String>, D::Error> {
310    // CloudFormation represents its optional String parameters as empty strings.
311    Ok(Option::<String>::deserialize(deserializer)?.filter(|machine| !machine.is_empty()))
312}
313
314impl ComputePoolSelection {
315    /// Selected provider machine type, when this platform needs one.
316    pub fn machine(&self) -> Option<&str> {
317        match self {
318            Self::Fixed { machine, .. } | Self::Autoscale { machine, .. } => machine.as_deref(),
319        }
320    }
321
322    /// Selected failure-domain policy, if this deployment explicitly adopted one.
323    pub fn failure_domains(&self) -> Option<&FailureDomainSelection> {
324        match self {
325            Self::Fixed {
326                failure_domains, ..
327            }
328            | Self::Autoscale {
329                failure_domains, ..
330            } => failure_domains.as_ref(),
331        }
332    }
333
334    /// Selected minimum machine count.
335    pub fn min_size(&self) -> u32 {
336        match self {
337            Self::Fixed { machines, .. } => *machines,
338            Self::Autoscale { min, .. } => *min,
339        }
340    }
341
342    /// Selected maximum machine count.
343    pub fn max_size(&self) -> u32 {
344        match self {
345            Self::Fixed { machines, .. } => *machines,
346            Self::Autoscale { max, .. } => *max,
347        }
348    }
349
350    /// Whether the selection has internally valid scale bounds.
351    pub fn validate(&self) -> std::result::Result<(), String> {
352        if self
353            .failure_domains()
354            .is_some_and(|selection| selection.spread == 0)
355        {
356            return Err("failure-domain spread must be at least one".to_string());
357        }
358        if self.failure_domains().is_some_and(|selection| {
359            !selection.selected_failure_domains.is_empty()
360                && selection.selected_failure_domains.len() != usize::from(selection.spread)
361        }) {
362            return Err("selected failure domains must match the requested spread".to_string());
363        }
364        if self.failure_domains().is_some_and(|selection| {
365            selection.selected_failure_domains.len()
366                != selection
367                    .selected_failure_domains
368                    .iter()
369                    .collect::<std::collections::HashSet<_>>()
370                    .len()
371        }) {
372            return Err("selected failure domains must be unique".to_string());
373        }
374        match self {
375            Self::Fixed { machines, .. } => {
376                if *machines == 0 {
377                    return Err("fixed compute pools must select at least one machine".to_string());
378                }
379                if let Some(failure_domains) = self.failure_domains() {
380                    if *machines < u32::from(failure_domains.spread) {
381                        return Err(format!(
382                            "fixed compute pool machines ({machines}) must be at least failure-domain spread ({})",
383                            failure_domains.spread
384                        ));
385                    }
386                }
387            }
388            Self::Autoscale { min, max, .. } => {
389                if min > max {
390                    return Err(format!(
391                        "autoscaling compute pool minimum ({min}) cannot exceed maximum ({max})"
392                    ));
393                }
394                if let Some(failure_domains) = self.failure_domains() {
395                    let spread = u32::from(failure_domains.spread);
396                    if *max < spread {
397                        return Err(format!(
398                            "autoscaling compute pool maximum ({max}) must be at least failure-domain spread ({spread})"
399                        ));
400                    }
401                    if *min < spread {
402                        return Err(format!(
403                            "autoscaling compute pool minimum ({min}) must be at least failure-domain spread ({spread})"
404                        ));
405                    }
406                }
407            }
408        }
409        Ok(())
410    }
411}
412
413/// Deployment model: how updates are delivered to the remote environment.
414#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
415#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
416#[serde(rename_all = "camelCase")]
417pub enum DeploymentModel {
418    /// Manager pushes updates via cross-account access.
419    /// Available for AWS, GCP, Azure only.
420    #[default]
421    Push,
422    /// Agent in remote environment pulls updates.
423    /// Available for all platforms (AWS, GCP, Azure, Kubernetes, Local).
424    Pull,
425}
426
427/// How updates are delivered to the deployment.
428#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
429#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
430#[serde(rename_all = "kebab-case")]
431pub enum UpdatesMode {
432    /// Updates deploy automatically (default).
433    #[default]
434    Auto,
435    /// Updates require explicit approval before deployment.
436    ApprovalRequired,
437}
438
439/// How telemetry (logs, metrics, traces) is handled.
440#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
441#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
442#[serde(rename_all = "kebab-case")]
443pub enum TelemetryMode {
444    /// No telemetry permissions. Data will not be collected.
445    Off,
446    /// Telemetry flows automatically (default).
447    #[default]
448    Auto,
449    /// Telemetry requires explicit approval before collection begins.
450    ApprovalRequired,
451}
452
453impl TelemetryMode {
454    /// Returns true if telemetry is enabled (Auto or ApprovalRequired).
455    pub fn is_enabled(&self) -> bool {
456        !matches!(self, TelemetryMode::Off)
457    }
458}
459
460/// How heartbeat health checks are handled.
461#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
462#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
463#[serde(rename_all = "kebab-case")]
464pub enum HeartbeatsMode {
465    /// No heartbeat permissions. Health checks disabled.
466    Off,
467    /// Heartbeat enabled (default).
468    #[default]
469    On,
470}
471
472impl HeartbeatsMode {
473    /// Returns true if heartbeat is enabled.
474    pub fn is_enabled(&self) -> bool {
475        matches!(self, HeartbeatsMode::On)
476    }
477}
478
479/// Reachability of the deployment's public endpoints, fixed at setup.
480#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
481#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
482#[serde(rename_all = "camelCase")]
483pub enum EndpointAccess {
484    /// Endpoints are reachable from the internet.
485    #[default]
486    Internet,
487    /// Endpoints are reachable only through the deployment network (AWS only).
488    Private,
489}
490
491impl EndpointAccess {
492    /// Serialized setup parameter value.
493    pub fn as_str(self) -> &'static str {
494        match self {
495            Self::Internet => "internet",
496            Self::Private => "private",
497        }
498    }
499}
500
501/// Domain configuration for the stack.
502///
503/// When `custom_domains` is set, the specified resources use customer-provided
504/// domains and certificates. Otherwise, Alien auto-generates domains.
505#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
506#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
507#[serde(rename_all = "camelCase")]
508pub struct DomainSettings {
509    /// Custom domain configuration per resource ID.
510    #[serde(default, skip_serializing_if = "Option::is_none")]
511    pub custom_domains: Option<HashMap<String, CustomDomainConfig>>,
512    /// Public endpoint DNS target selection for machines deployments.
513    ///
514    /// When omitted, machines deployments publish healthy machine public
515    /// addresses directly. Use `LoadBalancer` when an external load balancer
516    /// fronts the machines and Alien should publish a CNAME to that target.
517    #[serde(default, skip_serializing_if = "Option::is_none")]
518    pub public_endpoint_target: Option<PublicEndpointTargetSettings>,
519}
520
521/// DNS target mode for public endpoints.
522#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
523#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
524#[serde(rename_all = "camelCase", tag = "mode")]
525pub enum PublicEndpointTargetSettings {
526    /// Publish DNS records directly to healthy machine public IP addresses.
527    MachineAddresses,
528    /// Publish a CNAME record to an external load balancer.
529    #[serde(rename_all = "camelCase")]
530    LoadBalancer {
531        /// DNS name or URL for the external load balancer.
532        cname_target: String,
533    },
534}
535
536/// Custom domain configuration for a single resource.
537#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
538#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
539#[serde(rename_all = "camelCase")]
540pub struct CustomDomainConfig {
541    /// Fully qualified domain name to use.
542    pub domain: String,
543    /// Customer-provided certificate reference.
544    pub certificate: CustomCertificateConfig,
545}
546
547/// Platform-specific certificate references for custom domains.
548#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
549#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
550#[serde(rename_all = "camelCase")]
551pub struct CustomCertificateConfig {
552    /// AWS ACM certificate ARN
553    #[serde(default, skip_serializing_if = "Option::is_none")]
554    pub aws: Option<AwsCustomCertificateConfig>,
555    /// GCP Certificate Manager certificate name
556    #[serde(default, skip_serializing_if = "Option::is_none")]
557    pub gcp: Option<GcpCustomCertificateConfig>,
558    /// Azure Key Vault certificate ID
559    #[serde(default, skip_serializing_if = "Option::is_none")]
560    pub azure: Option<AzureCustomCertificateConfig>,
561    /// Kubernetes TLS Secret reference for Secret-backed route profiles.
562    #[serde(default, skip_serializing_if = "Option::is_none")]
563    pub kubernetes: Option<KubernetesCustomCertificateConfig>,
564}
565
566#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
567#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
568#[serde(rename_all = "camelCase")]
569pub struct AwsCustomCertificateConfig {
570    pub certificate_arn: String,
571}
572
573#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
574#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
575#[serde(rename_all = "camelCase")]
576pub struct GcpCustomCertificateConfig {
577    pub certificate_name: String,
578}
579
580#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
581#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
582#[serde(rename_all = "camelCase")]
583pub struct AzureCustomCertificateConfig {
584    pub key_vault_certificate_id: String,
585    #[serde(default, skip_serializing_if = "Option::is_none")]
586    pub key_vault_resource_id: Option<String>,
587}
588
589#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
590#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
591#[serde(rename_all = "camelCase")]
592pub struct KubernetesCustomCertificateConfig {
593    /// Existing TLS Secret containing `tls.crt` and `tls.key`.
594    pub tls_secret_ref: KubernetesTlsSecretRef,
595}
596
597/// Kubernetes runtime substrate configuration.
598///
599/// This controls how setup chooses the cluster backing `Platform::Kubernetes`
600/// deployments. When omitted, cloud-backed Kubernetes deployments default to a
601/// managed cluster and generic/on-prem Kubernetes defaults to an external
602/// cluster.
603#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
604#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
605#[serde(rename_all = "camelCase")]
606pub struct KubernetesSettings {
607    /// Cluster selection or creation settings.
608    #[serde(default, skip_serializing_if = "Option::is_none")]
609    pub cluster: Option<KubernetesClusterSettings>,
610    /// Public HTTPS exposure contract shared by setup, Helm, and runtime.
611    #[serde(default, skip_serializing_if = "Option::is_none")]
612    pub exposure: Option<KubernetesExposureSettings>,
613}
614
615/// Kubernetes cluster setup settings.
616#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
617#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
618#[serde(rename_all = "camelCase")]
619pub struct KubernetesClusterSettings {
620    /// Whether Alien should create the cluster, use a setup-owned existing
621    /// cluster, or bind to an external/on-prem cluster.
622    pub ownership: KubernetesClusterOwnership,
623    /// Namespace where the Alien chart and application resources run.
624    #[serde(default, skip_serializing_if = "Option::is_none")]
625    pub namespace: Option<String>,
626    /// Optional provider-specific cloud identity for existing clusters.
627    #[serde(default, skip_serializing_if = "Option::is_none")]
628    pub cloud: Option<KubernetesCloudReference>,
629}
630
631/// Kubernetes public HTTPS exposure mode.
632#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
633#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
634#[serde(rename_all = "camelCase", tag = "mode")]
635pub enum KubernetesExposureSettings {
636    /// Do not create Alien-managed external routing.
637    Disabled,
638    /// Use Alien-generated DNS and Platform-managed certificate material.
639    Generated {
640        /// Runtime route profile to materialize.
641        route: KubernetesRouteProfile,
642        /// How managed certificate material reaches the route profile.
643        certificate: KubernetesCertificateMode,
644    },
645    /// Use a customer hostname and customer-owned certificate reference.
646    Custom {
647        /// Hostname routed by the Kubernetes public endpoint.
648        domain: String,
649        /// Runtime route profile to materialize.
650        route: KubernetesRouteProfile,
651        /// Customer-owned certificate reference consumed by the route profile.
652        certificate: KubernetesCertificateMode,
653    },
654}
655
656/// Kubernetes route API selected for public endpoints.
657#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
658#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
659#[serde(rename_all = "camelCase", tag = "routeApi")]
660pub enum KubernetesRouteProfile {
661    /// `networking.k8s.io/v1` Ingress route profile.
662    Ingress(KubernetesIngressRouteProfile),
663    /// Gateway API `Gateway` + `HTTPRoute` route profile.
664    Gateway(KubernetesGatewayRouteProfile),
665}
666
667/// Shared Ingress route profile values.
668#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
669#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
670#[serde(rename_all = "camelCase")]
671pub struct KubernetesIngressRouteProfile {
672    /// Route controller identifier, for example `eks.amazonaws.com/alb`.
673    #[serde(default, skip_serializing_if = "Option::is_none")]
674    pub controller: Option<String>,
675    /// `spec.ingressClassName` for generated Ingresses.
676    pub ingress_class_name: String,
677    /// Labels applied to route objects.
678    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
679    pub labels: HashMap<String, String>,
680    /// Annotations applied to route objects.
681    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
682    pub annotations: HashMap<String, String>,
683    /// Provider-specific route options that are required by the selected class.
684    #[serde(default, skip_serializing_if = "Option::is_none")]
685    pub provider: Option<KubernetesRouteProviderOptions>,
686}
687
688/// Shared Gateway API route profile values.
689#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
690#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
691#[serde(rename_all = "camelCase")]
692pub struct KubernetesGatewayRouteProfile {
693    /// Route controller identifier, for example a cloud Gateway controller.
694    #[serde(default, skip_serializing_if = "Option::is_none")]
695    pub controller: Option<String>,
696    /// GatewayClass selected for generated Gateways.
697    pub gateway_class_name: String,
698    /// Listener port, usually 443.
699    pub listener_port: u16,
700    /// Labels applied to route objects.
701    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
702    pub labels: HashMap<String, String>,
703    /// Annotations applied to route objects.
704    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
705    pub annotations: HashMap<String, String>,
706    /// Provider-specific route options that are required by the selected class.
707    #[serde(default, skip_serializing_if = "Option::is_none")]
708    pub provider: Option<KubernetesRouteProviderOptions>,
709}
710
711/// Provider-specific route options required by supported managed profiles.
712#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
713#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
714#[serde(rename_all = "camelCase", tag = "provider")]
715pub enum KubernetesRouteProviderOptions {
716    /// AWS ALB route options for EKS.
717    #[serde(rename_all = "camelCase")]
718    AwsAlb {
719        /// Internet-facing or internal ALB scheme.
720        scheme: String,
721        /// ALB target type, usually `ip`.
722        target_type: String,
723        /// Optional ALB IP address type, such as `dualstack`.
724        #[serde(default, skip_serializing_if = "Option::is_none")]
725        ip_address_type: Option<String>,
726        /// Explicit subnet IDs when the profile cannot rely on controller discovery.
727        #[serde(default, skip_serializing_if = "Vec::is_empty")]
728        subnet_ids: Vec<String>,
729    },
730    /// GKE Gateway route options.
731    #[serde(rename_all = "camelCase")]
732    GkeGateway {
733        /// Optional static address name for the Gateway frontend.
734        #[serde(default, skip_serializing_if = "Option::is_none")]
735        static_address_name: Option<String>,
736    },
737    /// Azure Application Gateway for Containers route options.
738    #[serde(rename_all = "camelCase")]
739    AzureApplicationGatewayForContainers {
740        /// Optional ALB namespace when using BYO Application Gateway resources.
741        #[serde(default, skip_serializing_if = "Option::is_none")]
742        alb_namespace: Option<String>,
743        /// Optional ALB name when using BYO Application Gateway resources.
744        #[serde(default, skip_serializing_if = "Option::is_none")]
745        alb_name: Option<String>,
746        /// Public or internal frontend exposure.
747        frontend: String,
748    },
749}
750
751/// Certificate publication or reference mode for Kubernetes public endpoints.
752#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
753#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
754#[serde(rename_all = "camelCase", tag = "mode")]
755pub enum KubernetesCertificateMode {
756    /// Platform-managed cert imported into AWS ACM by the runtime.
757    #[serde(rename_all = "camelCase")]
758    ManagedAcmImport {
759        /// ACM region. Defaults to the deployment region when omitted.
760        #[serde(default, skip_serializing_if = "Option::is_none")]
761        region: Option<String>,
762        /// Tags applied to runtime-imported ACM certificates.
763        #[serde(default, skip_serializing_if = "HashMap::is_empty")]
764        tags: HashMap<String, String>,
765    },
766    /// Customer-provided AWS ACM certificate ARN.
767    #[serde(rename_all = "camelCase")]
768    AwsAcmArn {
769        /// Existing ACM certificate ARN.
770        certificate_arn: String,
771    },
772    /// Platform-managed cert written to a Kubernetes TLS Secret.
773    #[serde(rename_all = "camelCase")]
774    ManagedTlsSecret {
775        /// Secret name template. Runtime may substitute resource/deployment tokens.
776        secret_name_template: String,
777    },
778    /// Customer-provided Kubernetes TLS Secret.
779    TlsSecretRef(KubernetesTlsSecretRef),
780    /// No TLS certificate should be configured by Alien.
781    None,
782}
783
784/// Namespace-scoped Kubernetes TLS Secret reference.
785#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
786#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
787#[serde(rename_all = "camelCase")]
788pub struct KubernetesTlsSecretRef {
789    /// Secret name.
790    pub secret_name: String,
791    /// Secret namespace. Defaults to the release namespace when omitted.
792    #[serde(default, skip_serializing_if = "Option::is_none")]
793    pub namespace: Option<String>,
794}
795
796/// User-customizable deployment settings specified at deploy time.
797///
798/// These settings are provided by the customer via CloudFormation parameters,
799/// Terraform attributes, CLI flags, or Helm values. They customize how the
800/// deployment runs and what capabilities are enabled.
801///
802/// **Key distinction**: StackSettings is user-customizable, while ManagementConfig
803/// is platform-derived (from the Manager's ServiceAccount).
804#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
805#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
806#[serde(rename_all = "camelCase")]
807pub struct StackSettings {
808    /// Ongoing authority over infrastructure normally owned by setup. Automatic requires
809    /// an explicit CLI setup grant; changing this setting alone never grants that authority.
810    #[serde(default, skip_serializing_if = "is_manual_setup_management")]
811    pub setup_management: SetupManagement,
812    /// Provider quota identities whose preflight failures become visible warnings
813    /// for this deployment. Checks still run; deploy-time provider limits still apply.
814    /// Unknown identities are rejected. An empty list restores normal blocking.
815    #[serde(default, skip_serializing_if = "Vec::is_empty")]
816    pub quota_warning_overrides: Vec<String>,
817
818    /// Who can reach public endpoints. Private access requires AWS managed containers.
819    /// This choice cannot change after setup; create a new deployment to change it.
820    #[serde(default)]
821    pub endpoint_access: EndpointAccess,
822
823    /// Network configuration for the stack (VPC/VNet settings).
824    /// If `None`, an isolated VPC with NAT is auto-created when the stack has resources
825    /// that require networking (e.g., containers). Set explicitly to customize:
826    /// `UseDefault` for the provider's default network (fast, dev/test only),
827    /// `Create` for an isolated VPC with managed NAT (production), or `ByoVpc*`
828    /// to reference an existing customer-managed VPC.
829    #[serde(default, skip_serializing_if = "Option::is_none")]
830    pub network: Option<NetworkSettings>,
831
832    /// Domain configuration (future).
833    #[serde(default, skip_serializing_if = "Option::is_none")]
834    pub domains: Option<DomainSettings>,
835
836    /// Kubernetes runtime substrate configuration.
837    #[serde(default, skip_serializing_if = "Option::is_none")]
838    pub kubernetes: Option<KubernetesSettings>,
839
840    /// Deployment-time compute selections for Alien-managed compute pools.
841    ///
842    /// This is where provider machine names such as EC2 instance types, GCE
843    /// machine types, or Azure VM SKUs belong. Application source should
844    /// declare portable requirements instead.
845    #[serde(default, skip_serializing_if = "Option::is_none")]
846    pub compute: Option<ComputeSettings>,
847
848    /// Exact externally managed endpoint URLs, keyed by resource ID and endpoint name.
849    ///
850    /// This is intended for adopted Machines deployments whose DNS and certificates remain
851    /// customer-owned. The platform passes these URLs to the runtime without creating or
852    /// replacing DNS records or certificates.
853    #[serde(default, skip_serializing_if = "Option::is_none")]
854    #[cfg_attr(
855        feature = "openapi",
856        schema(value_type = Option<HashMap<String, HashMap<String, String>>>)
857    )]
858    pub public_endpoints: Option<crate::PublicEndpointUrls>,
859
860    /// Deployment model: push (Manager) or pull (Agent).
861    /// Default: Push.
862    /// - Push: Manager drives updates. For cloud platforms, requires cross-account
863    ///   credentials established during initial setup. For push-mode local
864    ///   deployments (currently `alien dev`), the manager has direct access —
865    ///   no bootstrap needed.
866    /// - Pull: Agent in the target environment drives updates via polling.
867    ///   Required for Kubernetes and remote local deployments.
868    #[serde(default, skip_serializing_if = "is_default_deployment_model")]
869    pub deployment_model: DeploymentModel,
870
871    /// How updates are delivered.
872    /// - auto: Updates deploy automatically (default)
873    /// - approval-required: Updates wait for explicit approval
874    #[serde(default, skip_serializing_if = "is_default_updates_mode")]
875    pub updates: UpdatesMode,
876
877    /// How telemetry (logs, metrics, traces) is handled.
878    /// - off: No telemetry permissions
879    /// - auto: Telemetry flows automatically (default)
880    /// - approval-required: Telemetry waits for explicit approval
881    #[serde(default, skip_serializing_if = "is_default_telemetry_mode")]
882    pub telemetry: TelemetryMode,
883
884    /// How heartbeat health checks are handled.
885    /// - off: No heartbeat permissions
886    /// - on: Heartbeat enabled (default)
887    #[serde(default, skip_serializing_if = "is_default_heartbeats_mode")]
888    pub heartbeats: HeartbeatsMode,
889
890    /// External bindings for pre-existing infrastructure.
891    /// Allows using existing resources (MinIO, Redis, shared Container Apps
892    /// Environment, etc.) instead of having Alien provision them.
893    /// Required for Kubernetes platform, optional for cloud platforms.
894    #[serde(default, skip_serializing_if = "Option::is_none")]
895    pub external_bindings: Option<crate::ExternalBindings>,
896}
897
898/// Whether setup remains a separate administrator operation.
899#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
900#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
901#[serde(rename_all = "camelCase")]
902pub enum SetupManagement {
903    /// Setup owns structural infrastructure changes and teardown.
904    #[default]
905    Manual,
906    /// Alien continues managing setup infrastructure with permissions explicitly granted at setup.
907    Automatic,
908}
909
910fn is_manual_setup_management(mode: &SetupManagement) -> bool {
911    *mode == SetupManagement::Manual
912}
913
914fn is_default_deployment_model(model: &DeploymentModel) -> bool {
915    *model == DeploymentModel::default()
916}
917
918fn is_default_updates_mode(mode: &UpdatesMode) -> bool {
919    *mode == UpdatesMode::default()
920}
921
922fn is_default_telemetry_mode(mode: &TelemetryMode) -> bool {
923    *mode == TelemetryMode::default()
924}
925
926fn is_default_heartbeats_mode(mode: &HeartbeatsMode) -> bool {
927    *mode == HeartbeatsMode::default()
928}
929
930#[cfg(test)]
931mod failure_domain_tests {
932    use super::*;
933
934    #[test]
935    fn old_compute_selection_deserializes_without_topology() {
936        let selection: ComputePoolSelection = serde_json::from_value(serde_json::json!({
937            "mode": "fixed",
938            "machines": 2,
939            "machine": "m7i.xlarge"
940        }))
941        .expect("existing selection should deserialize");
942        assert!(selection.failure_domains().is_none());
943    }
944
945    #[test]
946    fn rejects_duplicate_concrete_failure_domains() {
947        let selection = ComputePoolSelection::Fixed {
948            machines: 2,
949            machine: Some("m7i.xlarge".to_string()),
950            failure_domains: Some(FailureDomainSelection {
951                spread: 2,
952                selected_failure_domains: vec!["us-west-2a".to_string(); 2],
953            }),
954        };
955        assert_eq!(
956            selection.validate(),
957            Err("selected failure domains must be unique".to_string())
958        );
959    }
960
961    #[test]
962    fn fixed_pool_must_have_one_machine_per_failure_domain() {
963        let invalid = ComputePoolSelection::Fixed {
964            machines: 1,
965            machine: None,
966            failure_domains: Some(FailureDomainSelection {
967                spread: 2,
968                selected_failure_domains: Vec::new(),
969            }),
970        };
971        assert_eq!(
972            invalid.validate(),
973            Err(
974                "fixed compute pool machines (1) must be at least failure-domain spread (2)"
975                    .to_string()
976            )
977        );
978
979        let valid = ComputePoolSelection::Fixed {
980            machines: 2,
981            machine: None,
982            failure_domains: Some(FailureDomainSelection {
983                spread: 2,
984                selected_failure_domains: Vec::new(),
985            }),
986        };
987        assert_eq!(valid.validate(), Ok(()));
988    }
989
990    #[test]
991    fn autoscaling_pool_bounds_must_cover_every_failure_domain() {
992        let invalid_max = ComputePoolSelection::Autoscale {
993            min: 1,
994            max: 1,
995            machine: None,
996            failure_domains: Some(FailureDomainSelection {
997                spread: 2,
998                selected_failure_domains: Vec::new(),
999            }),
1000        };
1001        assert_eq!(
1002            invalid_max.validate(),
1003            Err(
1004                "autoscaling compute pool maximum (1) must be at least failure-domain spread (2)"
1005                    .to_string()
1006            )
1007        );
1008
1009        let invalid_min = ComputePoolSelection::Autoscale {
1010            min: 1,
1011            max: 3,
1012            machine: None,
1013            failure_domains: Some(FailureDomainSelection {
1014                spread: 2,
1015                selected_failure_domains: Vec::new(),
1016            }),
1017        };
1018        assert_eq!(
1019            invalid_min.validate(),
1020            Err(
1021                "autoscaling compute pool minimum (1) must be at least failure-domain spread (2)"
1022                    .to_string()
1023            )
1024        );
1025
1026        let valid = ComputePoolSelection::Autoscale {
1027            min: 2,
1028            max: 2,
1029            machine: None,
1030            failure_domains: Some(FailureDomainSelection {
1031                spread: 2,
1032                selected_failure_domains: Vec::new(),
1033            }),
1034        };
1035        assert_eq!(valid.validate(), Ok(()));
1036    }
1037
1038    #[test]
1039    fn machine_public_endpoints_round_trip_without_rewriting_urls() {
1040        let settings: StackSettings = serde_json::from_value(serde_json::json!({
1041            "publicEndpoints": {
1042                "loader": {
1043                    "api": "https://loader.compute.example.com",
1044                    "shares": "https://shares.compute.example.com",
1045                    "webhooks": "https://webhooks.compute.example.com"
1046                }
1047            }
1048        }))
1049        .expect("stack settings should deserialize");
1050
1051        assert_eq!(
1052            serde_json::to_value(settings).expect("stack settings should serialize")
1053                ["publicEndpoints"]["loader"]["shares"],
1054            "https://shares.compute.example.com"
1055        );
1056    }
1057}