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