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 with supplied or managed 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 for a supplied certificate.
542    #[serde(default, skip_serializing_if = "String::is_empty")]
543    pub domain: String,
544    /// Hostnames keyed by endpoint name for a managed certificate.
545    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
546    pub endpoints: HashMap<String, String>,
547    /// Supplied certificate reference or managed issuance.
548    pub certificate: CustomCertificateConfig,
549}
550
551/// Platform-specific certificate references for custom domains.
552#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
553#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
554#[serde(rename_all = "camelCase")]
555pub struct CustomCertificateConfig {
556    /// Issue and renew a certificate for the configured endpoint hostnames.
557    #[serde(default, skip_serializing_if = "Option::is_none")]
558    pub managed: Option<ManagedCustomCertificateConfig>,
559    /// AWS ACM certificate ARN
560    #[serde(default, skip_serializing_if = "Option::is_none")]
561    pub aws: Option<AwsCustomCertificateConfig>,
562    /// GCP Certificate Manager certificate name
563    #[serde(default, skip_serializing_if = "Option::is_none")]
564    pub gcp: Option<GcpCustomCertificateConfig>,
565    /// Azure Key Vault certificate ID
566    #[serde(default, skip_serializing_if = "Option::is_none")]
567    pub azure: Option<AzureCustomCertificateConfig>,
568    /// Kubernetes TLS Secret reference for Secret-backed route profiles.
569    #[serde(default, skip_serializing_if = "Option::is_none")]
570    pub kubernetes: Option<KubernetesCustomCertificateConfig>,
571}
572
573/// Managed certificate issuance settings.
574#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
575#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
576#[serde(rename_all = "camelCase", deny_unknown_fields)]
577pub struct ManagedCustomCertificateConfig {}
578
579#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
580#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
581#[serde(rename_all = "camelCase")]
582pub struct AwsCustomCertificateConfig {
583    pub certificate_arn: String,
584}
585
586#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
587#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
588#[serde(rename_all = "camelCase")]
589pub struct GcpCustomCertificateConfig {
590    pub certificate_name: String,
591}
592
593#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
594#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
595#[serde(rename_all = "camelCase")]
596pub struct AzureCustomCertificateConfig {
597    pub key_vault_certificate_id: String,
598    #[serde(default, skip_serializing_if = "Option::is_none")]
599    pub key_vault_resource_id: Option<String>,
600}
601
602#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
603#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
604#[serde(rename_all = "camelCase")]
605pub struct KubernetesCustomCertificateConfig {
606    /// Existing TLS Secret containing `tls.crt` and `tls.key`.
607    pub tls_secret_ref: KubernetesTlsSecretRef,
608}
609
610/// Kubernetes runtime substrate configuration.
611///
612/// This controls how setup chooses the cluster backing `Platform::Kubernetes`
613/// deployments. When omitted, cloud-backed Kubernetes deployments default to a
614/// managed cluster and generic/on-prem Kubernetes defaults to an external
615/// cluster.
616#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
617#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
618#[serde(rename_all = "camelCase")]
619pub struct KubernetesSettings {
620    /// Cluster selection or creation settings.
621    #[serde(default, skip_serializing_if = "Option::is_none")]
622    pub cluster: Option<KubernetesClusterSettings>,
623    /// Public HTTPS exposure contract shared by setup, Helm, and runtime.
624    #[serde(default, skip_serializing_if = "Option::is_none")]
625    pub exposure: Option<KubernetesExposureSettings>,
626}
627
628/// Kubernetes cluster setup settings.
629#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
630#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
631#[serde(rename_all = "camelCase")]
632pub struct KubernetesClusterSettings {
633    /// Whether Alien should create the cluster, use a setup-owned existing
634    /// cluster, or bind to an external/on-prem cluster.
635    pub ownership: KubernetesClusterOwnership,
636    /// Namespace where the Alien chart and application resources run.
637    #[serde(default, skip_serializing_if = "Option::is_none")]
638    pub namespace: Option<String>,
639    /// Optional provider-specific cloud identity for existing clusters.
640    #[serde(default, skip_serializing_if = "Option::is_none")]
641    pub cloud: Option<KubernetesCloudReference>,
642}
643
644/// Kubernetes public HTTPS exposure mode.
645#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
646#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
647#[serde(rename_all = "camelCase", tag = "mode")]
648pub enum KubernetesExposureSettings {
649    /// Do not create Alien-managed external routing.
650    Disabled,
651    /// Use Alien-generated DNS and Platform-managed certificate material.
652    Generated {
653        /// Runtime route profile to materialize.
654        route: KubernetesRouteProfile,
655        /// How managed certificate material reaches the route profile.
656        certificate: KubernetesCertificateMode,
657    },
658    /// Use a customer hostname and customer-owned certificate reference.
659    Custom {
660        /// Hostname routed by the Kubernetes public endpoint.
661        domain: String,
662        /// Runtime route profile to materialize.
663        route: KubernetesRouteProfile,
664        /// Customer-owned certificate reference consumed by the route profile.
665        certificate: KubernetesCertificateMode,
666    },
667    /// Give each named public endpoint a hostname below a customer-owned base domain.
668    #[serde(rename_all = "camelCase")]
669    CustomBaseDomain {
670        /// DNS name without a wildcard or URL scheme, for example `app.example.com`.
671        base_domain: String,
672        /// Route profile shared by all public endpoints.
673        route: KubernetesRouteProfile,
674        /// One customer-owned certificate reference covering every endpoint hostname.
675        certificate: KubernetesCertificateMode,
676    },
677}
678
679/// Kubernetes route API selected for public endpoints.
680#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
681#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
682#[serde(rename_all = "camelCase", tag = "routeApi")]
683pub enum KubernetesRouteProfile {
684    /// `networking.k8s.io/v1` Ingress route profile.
685    Ingress(KubernetesIngressRouteProfile),
686    /// Gateway API `Gateway` + `HTTPRoute` route profile.
687    Gateway(KubernetesGatewayRouteProfile),
688}
689
690/// Shared Ingress route profile values.
691#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
692#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
693#[serde(rename_all = "camelCase")]
694pub struct KubernetesIngressRouteProfile {
695    /// Route controller identifier, for example `eks.amazonaws.com/alb`.
696    #[serde(default, skip_serializing_if = "Option::is_none")]
697    pub controller: Option<String>,
698    /// `spec.ingressClassName` for generated Ingresses.
699    pub ingress_class_name: String,
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/// Shared Gateway API route profile values.
712#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
713#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
714#[serde(rename_all = "camelCase")]
715pub struct KubernetesGatewayRouteProfile {
716    /// Route controller identifier, for example a cloud Gateway controller.
717    #[serde(default, skip_serializing_if = "Option::is_none")]
718    pub controller: Option<String>,
719    /// GatewayClass selected for generated Gateways.
720    pub gateway_class_name: String,
721    /// Listener port, usually 443.
722    pub listener_port: u16,
723    /// Labels applied to route objects.
724    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
725    pub labels: HashMap<String, String>,
726    /// Annotations applied to route objects.
727    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
728    pub annotations: HashMap<String, String>,
729    /// Provider-specific route options that are required by the selected class.
730    #[serde(default, skip_serializing_if = "Option::is_none")]
731    pub provider: Option<KubernetesRouteProviderOptions>,
732}
733
734/// Provider-specific route options required by supported managed profiles.
735#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
736#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
737#[serde(rename_all = "camelCase", tag = "provider")]
738pub enum KubernetesRouteProviderOptions {
739    /// AWS ALB route options for EKS.
740    #[serde(rename_all = "camelCase")]
741    AwsAlb {
742        /// Internet-facing or internal ALB scheme.
743        scheme: String,
744        /// ALB target type, usually `ip`.
745        target_type: String,
746        /// Optional ALB IP address type, such as `dualstack`.
747        #[serde(default, skip_serializing_if = "Option::is_none")]
748        ip_address_type: Option<String>,
749        /// Explicit subnet IDs when the profile cannot rely on controller discovery.
750        #[serde(default, skip_serializing_if = "Vec::is_empty")]
751        subnet_ids: Vec<String>,
752    },
753    /// GKE Gateway route options.
754    #[serde(rename_all = "camelCase")]
755    GkeGateway {
756        /// Optional static address name for the Gateway frontend.
757        #[serde(default, skip_serializing_if = "Option::is_none")]
758        static_address_name: Option<String>,
759    },
760    /// Azure Application Gateway for Containers route options.
761    #[serde(rename_all = "camelCase")]
762    AzureApplicationGatewayForContainers {
763        /// Optional ALB namespace when using BYO Application Gateway resources.
764        #[serde(default, skip_serializing_if = "Option::is_none")]
765        alb_namespace: Option<String>,
766        /// Optional ALB name when using BYO Application Gateway resources.
767        #[serde(default, skip_serializing_if = "Option::is_none")]
768        alb_name: Option<String>,
769        /// Public or internal frontend exposure.
770        frontend: String,
771    },
772}
773
774/// Certificate publication or reference mode for Kubernetes public endpoints.
775#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
776#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
777#[serde(rename_all = "camelCase", tag = "mode")]
778pub enum KubernetesCertificateMode {
779    /// Platform-managed cert imported into AWS ACM by the runtime.
780    #[serde(rename_all = "camelCase")]
781    ManagedAcmImport {
782        /// ACM region. Defaults to the deployment region when omitted.
783        #[serde(default, skip_serializing_if = "Option::is_none")]
784        region: Option<String>,
785        /// Tags applied to runtime-imported ACM certificates.
786        #[serde(default, skip_serializing_if = "HashMap::is_empty")]
787        tags: HashMap<String, String>,
788    },
789    /// Customer-provided AWS ACM certificate ARN.
790    #[serde(rename_all = "camelCase")]
791    AwsAcmArn {
792        /// Existing ACM certificate ARN.
793        certificate_arn: String,
794    },
795    /// Platform-managed cert written to a Kubernetes TLS Secret.
796    #[serde(rename_all = "camelCase")]
797    ManagedTlsSecret {
798        /// Secret name template. Runtime may substitute resource/deployment tokens.
799        secret_name_template: String,
800    },
801    /// Customer-provided Kubernetes TLS Secret.
802    TlsSecretRef(KubernetesTlsSecretRef),
803    /// No TLS certificate should be configured by Alien.
804    None,
805}
806
807/// Namespace-scoped Kubernetes TLS Secret reference.
808#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
809#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
810#[serde(rename_all = "camelCase")]
811pub struct KubernetesTlsSecretRef {
812    /// Secret name.
813    pub secret_name: String,
814    /// Secret namespace. Defaults to the release namespace when omitted.
815    #[serde(default, skip_serializing_if = "Option::is_none")]
816    pub namespace: Option<String>,
817}
818
819/// User-customizable deployment settings specified at deploy time.
820///
821/// These settings are provided by the customer via CloudFormation parameters,
822/// Terraform attributes, CLI flags, or Helm values. They customize how the
823/// deployment runs and what capabilities are enabled.
824///
825/// **Key distinction**: StackSettings is user-customizable, while ManagementConfig
826/// is platform-derived (from the Manager's ServiceAccount).
827#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
828#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
829#[serde(rename_all = "camelCase")]
830pub struct StackSettings {
831    /// Ongoing authority over infrastructure normally owned by setup. Automatic requires
832    /// an explicit CLI setup grant; changing this setting alone never grants that authority.
833    #[serde(default, skip_serializing_if = "is_manual_setup_management")]
834    pub setup_management: SetupManagement,
835    /// Provider quota identities whose preflight failures become visible warnings
836    /// for this deployment. Checks still run; deploy-time provider limits still apply.
837    /// Unknown identities are rejected. An empty list restores normal blocking.
838    #[serde(default, skip_serializing_if = "Vec::is_empty")]
839    pub quota_warning_overrides: Vec<String>,
840
841    /// Who can reach public endpoints. Private access requires AWS managed containers.
842    /// This choice cannot change after setup; create a new deployment to change it.
843    #[serde(default)]
844    pub endpoint_access: EndpointAccess,
845
846    /// Network configuration for the stack (VPC/VNet settings).
847    /// If `None`, an isolated VPC with NAT is auto-created when the stack has resources
848    /// that require networking (e.g., containers). Set explicitly to customize:
849    /// `UseDefault` for the provider's default network (fast, dev/test only),
850    /// `Create` for an isolated VPC with managed NAT (production), or `ByoVpc*`
851    /// to reference an existing customer-managed VPC.
852    #[serde(default, skip_serializing_if = "Option::is_none")]
853    pub network: Option<NetworkSettings>,
854
855    /// Domain configuration (future).
856    #[serde(default, skip_serializing_if = "Option::is_none")]
857    pub domains: Option<DomainSettings>,
858
859    /// Kubernetes runtime substrate configuration.
860    #[serde(default, skip_serializing_if = "Option::is_none")]
861    pub kubernetes: Option<KubernetesSettings>,
862
863    /// Deployment-time compute selections for Alien-managed compute pools.
864    ///
865    /// This is where provider machine names such as EC2 instance types, GCE
866    /// machine types, or Azure VM SKUs belong. Application source should
867    /// declare portable requirements instead.
868    #[serde(default, skip_serializing_if = "Option::is_none")]
869    pub compute: Option<ComputeSettings>,
870
871    /// Exact externally managed endpoint URLs, keyed by resource ID and endpoint name.
872    ///
873    /// This is intended for adopted Machines deployments whose DNS and certificates remain
874    /// customer-owned. The platform passes these URLs to the runtime without creating or
875    /// replacing DNS records or certificates.
876    #[serde(default, skip_serializing_if = "Option::is_none")]
877    #[cfg_attr(
878        feature = "openapi",
879        schema(value_type = Option<HashMap<String, HashMap<String, String>>>)
880    )]
881    pub public_endpoints: Option<crate::PublicEndpointUrls>,
882
883    /// Deployment model: push (Manager) or pull (Agent).
884    /// Default: Push.
885    /// - Push: Manager drives updates. For cloud platforms, requires cross-account
886    ///   credentials established during initial setup. For push-mode local
887    ///   deployments (currently `alien dev`), the manager has direct access —
888    ///   no bootstrap needed.
889    /// - Pull: Agent in the target environment drives updates via polling.
890    ///   Required for Kubernetes and remote local deployments.
891    #[serde(default, skip_serializing_if = "is_default_deployment_model")]
892    pub deployment_model: DeploymentModel,
893
894    /// Deliver Kubernetes updates and reports as carried bundles instead of a live connection.
895    /// The Operator still executes deployments with the pull model.
896    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
897    pub airgapped: bool,
898
899    /// How updates are delivered.
900    /// - auto: Updates deploy automatically (default)
901    /// - approval-required: Updates wait for explicit approval
902    #[serde(default, skip_serializing_if = "is_default_updates_mode")]
903    pub updates: UpdatesMode,
904
905    /// How telemetry (logs, metrics, traces) is handled.
906    /// - off: No telemetry permissions
907    /// - auto: Telemetry flows automatically (default)
908    /// - approval-required: Telemetry waits for explicit approval
909    #[serde(default, skip_serializing_if = "is_default_telemetry_mode")]
910    pub telemetry: TelemetryMode,
911
912    /// How heartbeat health checks are handled.
913    /// - off: No heartbeat permissions
914    /// - on: Heartbeat enabled (default)
915    #[serde(default, skip_serializing_if = "is_default_heartbeats_mode")]
916    pub heartbeats: HeartbeatsMode,
917
918    /// External bindings for pre-existing infrastructure.
919    /// Allows using existing resources (MinIO, Redis, shared Container Apps
920    /// Environment, etc.) instead of having Alien provision them.
921    /// Required for Kubernetes platform, optional for cloud platforms.
922    #[serde(default, skip_serializing_if = "Option::is_none")]
923    pub external_bindings: Option<crate::ExternalBindings>,
924}
925
926/// Whether setup remains a separate administrator operation.
927#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
928#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
929#[serde(rename_all = "camelCase")]
930pub enum SetupManagement {
931    /// Setup owns structural infrastructure changes and teardown.
932    #[default]
933    Manual,
934    /// Alien continues managing setup infrastructure with permissions explicitly granted at setup.
935    Automatic,
936}
937
938impl SetupManagement {
939    /// Serialized setup parameter value.
940    pub fn as_str(self) -> &'static str {
941        match self {
942            Self::Manual => "manual",
943            Self::Automatic => "automatic",
944        }
945    }
946}
947
948fn is_manual_setup_management(mode: &SetupManagement) -> bool {
949    *mode == SetupManagement::Manual
950}
951
952fn is_default_deployment_model(model: &DeploymentModel) -> bool {
953    *model == DeploymentModel::default()
954}
955
956fn is_default_updates_mode(mode: &UpdatesMode) -> bool {
957    *mode == UpdatesMode::default()
958}
959
960fn is_default_telemetry_mode(mode: &TelemetryMode) -> bool {
961    *mode == TelemetryMode::default()
962}
963
964fn is_default_heartbeats_mode(mode: &HeartbeatsMode) -> bool {
965    *mode == HeartbeatsMode::default()
966}
967
968#[cfg(test)]
969mod failure_domain_tests {
970    use super::*;
971
972    #[test]
973    fn old_compute_selection_deserializes_without_topology() {
974        let selection: ComputePoolSelection = serde_json::from_value(serde_json::json!({
975            "mode": "fixed",
976            "machines": 2,
977            "machine": "m7i.xlarge"
978        }))
979        .expect("existing selection should deserialize");
980        assert!(selection.failure_domains().is_none());
981    }
982
983    #[test]
984    fn rejects_duplicate_concrete_failure_domains() {
985        let selection = ComputePoolSelection::Fixed {
986            machines: 2,
987            machine: Some("m7i.xlarge".to_string()),
988            failure_domains: Some(FailureDomainSelection {
989                spread: 2,
990                selected_failure_domains: vec!["us-west-2a".to_string(); 2],
991            }),
992        };
993        assert_eq!(
994            selection.validate(),
995            Err("selected failure domains must be unique".to_string())
996        );
997    }
998
999    #[test]
1000    fn fixed_pool_must_have_one_machine_per_failure_domain() {
1001        let invalid = ComputePoolSelection::Fixed {
1002            machines: 1,
1003            machine: None,
1004            failure_domains: Some(FailureDomainSelection {
1005                spread: 2,
1006                selected_failure_domains: Vec::new(),
1007            }),
1008        };
1009        assert_eq!(
1010            invalid.validate(),
1011            Err(
1012                "fixed compute pool machines (1) must be at least failure-domain spread (2)"
1013                    .to_string()
1014            )
1015        );
1016
1017        let valid = ComputePoolSelection::Fixed {
1018            machines: 2,
1019            machine: None,
1020            failure_domains: Some(FailureDomainSelection {
1021                spread: 2,
1022                selected_failure_domains: Vec::new(),
1023            }),
1024        };
1025        assert_eq!(valid.validate(), Ok(()));
1026    }
1027
1028    #[test]
1029    fn autoscaling_pool_bounds_must_cover_every_failure_domain() {
1030        let invalid_max = ComputePoolSelection::Autoscale {
1031            min: 1,
1032            max: 1,
1033            machine: None,
1034            failure_domains: Some(FailureDomainSelection {
1035                spread: 2,
1036                selected_failure_domains: Vec::new(),
1037            }),
1038        };
1039        assert_eq!(
1040            invalid_max.validate(),
1041            Err(
1042                "autoscaling compute pool maximum (1) must be at least failure-domain spread (2)"
1043                    .to_string()
1044            )
1045        );
1046
1047        let invalid_min = ComputePoolSelection::Autoscale {
1048            min: 1,
1049            max: 3,
1050            machine: None,
1051            failure_domains: Some(FailureDomainSelection {
1052                spread: 2,
1053                selected_failure_domains: Vec::new(),
1054            }),
1055        };
1056        assert_eq!(
1057            invalid_min.validate(),
1058            Err(
1059                "autoscaling compute pool minimum (1) must be at least failure-domain spread (2)"
1060                    .to_string()
1061            )
1062        );
1063
1064        let valid = ComputePoolSelection::Autoscale {
1065            min: 2,
1066            max: 2,
1067            machine: None,
1068            failure_domains: Some(FailureDomainSelection {
1069                spread: 2,
1070                selected_failure_domains: Vec::new(),
1071            }),
1072        };
1073        assert_eq!(valid.validate(), Ok(()));
1074    }
1075
1076    #[test]
1077    fn machine_public_endpoints_round_trip_without_rewriting_urls() {
1078        let settings: StackSettings = serde_json::from_value(serde_json::json!({
1079            "publicEndpoints": {
1080                "loader": {
1081                    "api": "https://loader.compute.example.com",
1082                    "shares": "https://shares.compute.example.com",
1083                    "webhooks": "https://webhooks.compute.example.com"
1084                }
1085            }
1086        }))
1087        .expect("stack settings should deserialize");
1088
1089        assert_eq!(
1090            serde_json::to_value(settings).expect("stack settings should serialize")
1091                ["publicEndpoints"]["loader"]["shares"],
1092            "https://shares.compute.example.com"
1093        );
1094    }
1095
1096    #[test]
1097    fn custom_domains_preserve_managed_endpoint_names_and_supplied_certificates() {
1098        for config in [
1099            serde_json::json!({
1100                "endpoints": {
1101                    "api": "q7.m2x.example.com",
1102                    "shares": "*.q7.s9t.example.com",
1103                    "webhooks": "*.q7.w3h.example.com"
1104                },
1105                "certificate": {"managed": {}}
1106            }),
1107            serde_json::json!({
1108                "domain": "app.example.com",
1109                "certificate": {"aws": {"certificateArn": "arn:example"}}
1110            }),
1111        ] {
1112            let parsed: CustomDomainConfig = serde_json::from_value(config.clone())
1113                .expect("custom domain configuration should deserialize");
1114            assert_eq!(serde_json::to_value(parsed).unwrap(), config);
1115        }
1116    }
1117}