Skip to main content

alien_core/
stack_settings.rs

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