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