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