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