1use serde::{Deserialize, Serialize};
6use std::collections::HashMap;
7
8use crate::{KubernetesCloudReference, KubernetesClusterOwnership};
9
10#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
12#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
13#[serde(rename_all = "camelCase")]
14pub struct AwsManagementConfig {
15 pub managing_role_arn: String,
17}
18
19#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
21#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
22#[serde(rename_all = "camelCase")]
23pub struct GcpManagementConfig {
24 pub service_account_email: String,
26 #[serde(default, skip_serializing_if = "Option::is_none")]
29 pub federation_trust: Option<crate::GcpFederationTrust>,
30}
31
32#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
34#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
35#[serde(rename_all = "camelCase")]
36pub struct AzureManagementConfig {
37 pub managing_tenant_id: String,
39 pub oidc_issuer: String,
41 pub oidc_subject: String,
43}
44
45#[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(AwsManagementConfig),
55 Gcp(GcpManagementConfig),
57 Azure(AzureManagementConfig),
59 Kubernetes,
61}
62
63#[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 #[serde(rename = "use-default")]
96 UseDefault,
97
98 #[serde(rename = "create")]
105 Create {
106 #[serde(skip_serializing_if = "Option::is_none")]
109 cidr: Option<String>,
110
111 #[serde(default = "default_availability_zones")]
113 availability_zones: u8,
114 },
115
116 #[serde(rename = "byo-vpc-aws")]
121 ByoVpcAws {
122 vpc_id: String,
124 #[serde(deserialize_with = "crate::network_ids::deserialize_ids")]
126 public_subnet_ids: Vec<String>,
127 #[serde(deserialize_with = "crate::network_ids::deserialize_ids")]
129 private_subnet_ids: Vec<String>,
130 #[serde(default)]
132 #[serde(deserialize_with = "crate::network_ids::deserialize_ids")]
133 security_group_ids: Vec<String>,
134 },
135
136 #[serde(rename = "byo-vpc-gcp")]
141 ByoVpcGcp {
142 network_name: String,
144 subnet_name: String,
146 region: String,
148 },
149
150 #[serde(rename = "byo-vnet-azure")]
155 ByoVnetAzure {
156 vnet_resource_id: String,
158 public_subnet_name: String,
160 private_subnet_name: String,
162 #[serde(default, skip_serializing_if = "Option::is_none")]
164 application_gateway_subnet_name: Option<String>,
165 #[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
179pub const MAX_CREATED_NETWORK_AVAILABILITY_ZONES: u8 = 8;
183
184#[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 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
195 pub pools: HashMap<String, ComputePoolSelection>,
196 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
198 pub containers: HashMap<String, ContainerResourceSelection>,
199}
200
201#[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 #[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 #[serde(default, skip_serializing_if = "Option::is_none")]
216 pub memory: Option<String>,
217 #[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 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 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#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
262#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
263#[serde(rename_all = "camelCase")]
264pub struct FailureDomainSelection {
265 pub spread: u8,
267 #[serde(default, skip_serializing_if = "Vec::is_empty")]
270 pub selected_failure_domains: Vec<String>,
271}
272
273#[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 {
280 machines: u32,
282 #[serde(
284 default,
285 skip_serializing_if = "Option::is_none",
286 deserialize_with = "deserialize_machine_override"
287 )]
288 machine: Option<String>,
289 #[serde(default, skip_serializing_if = "Option::is_none")]
291 failure_domains: Option<FailureDomainSelection>,
292 },
293 Autoscale {
295 min: u32,
297 max: u32,
299 #[serde(
301 default,
302 skip_serializing_if = "Option::is_none",
303 deserialize_with = "deserialize_machine_override"
304 )]
305 machine: Option<String>,
306 #[serde(default, skip_serializing_if = "Option::is_none")]
308 failure_domains: Option<FailureDomainSelection>,
309 },
310}
311
312fn deserialize_machine_override<'de, D: serde::Deserializer<'de>>(
313 deserializer: D,
314) -> std::result::Result<Option<String>, D::Error> {
315 Ok(Option::<String>::deserialize(deserializer)?.filter(|machine| !machine.is_empty()))
317}
318
319impl ComputePoolSelection {
320 pub fn machine(&self) -> Option<&str> {
322 match self {
323 Self::Fixed { machine, .. } | Self::Autoscale { machine, .. } => machine.as_deref(),
324 }
325 }
326
327 pub fn failure_domains(&self) -> Option<&FailureDomainSelection> {
329 match self {
330 Self::Fixed {
331 failure_domains, ..
332 }
333 | Self::Autoscale {
334 failure_domains, ..
335 } => failure_domains.as_ref(),
336 }
337 }
338
339 pub fn min_size(&self) -> u32 {
341 match self {
342 Self::Fixed { machines, .. } => *machines,
343 Self::Autoscale { min, .. } => *min,
344 }
345 }
346
347 pub fn max_size(&self) -> u32 {
349 match self {
350 Self::Fixed { machines, .. } => *machines,
351 Self::Autoscale { max, .. } => *max,
352 }
353 }
354
355 pub fn validate(&self) -> std::result::Result<(), String> {
357 if self
358 .failure_domains()
359 .is_some_and(|selection| selection.spread == 0)
360 {
361 return Err("failure-domain spread must be at least one".to_string());
362 }
363 if self.failure_domains().is_some_and(|selection| {
364 !selection.selected_failure_domains.is_empty()
365 && selection.selected_failure_domains.len() != usize::from(selection.spread)
366 }) {
367 return Err("selected failure domains must match the requested spread".to_string());
368 }
369 if self.failure_domains().is_some_and(|selection| {
370 selection.selected_failure_domains.len()
371 != selection
372 .selected_failure_domains
373 .iter()
374 .collect::<std::collections::HashSet<_>>()
375 .len()
376 }) {
377 return Err("selected failure domains must be unique".to_string());
378 }
379 match self {
380 Self::Fixed { machines, .. } => {
381 if *machines == 0 {
382 return Err("fixed compute pools must select at least one machine".to_string());
383 }
384 if let Some(failure_domains) = self.failure_domains() {
385 if *machines < u32::from(failure_domains.spread) {
386 return Err(format!(
387 "fixed compute pool machines ({machines}) must be at least failure-domain spread ({})",
388 failure_domains.spread
389 ));
390 }
391 }
392 }
393 Self::Autoscale { min, max, .. } => {
394 if min > max {
395 return Err(format!(
396 "autoscaling compute pool minimum ({min}) cannot exceed maximum ({max})"
397 ));
398 }
399 if let Some(failure_domains) = self.failure_domains() {
400 let spread = u32::from(failure_domains.spread);
401 if *max < spread {
402 return Err(format!(
403 "autoscaling compute pool maximum ({max}) must be at least failure-domain spread ({spread})"
404 ));
405 }
406 if *min < spread {
407 return Err(format!(
408 "autoscaling compute pool minimum ({min}) must be at least failure-domain spread ({spread})"
409 ));
410 }
411 }
412 }
413 }
414 Ok(())
415 }
416}
417
418#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
420#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
421#[serde(rename_all = "camelCase")]
422pub enum DeploymentModel {
423 #[default]
426 Push,
427 Pull,
430}
431
432#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
434#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
435#[serde(rename_all = "kebab-case")]
436pub enum UpdatesMode {
437 #[default]
439 Auto,
440 ApprovalRequired,
442}
443
444#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
446#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
447#[serde(rename_all = "kebab-case")]
448pub enum TelemetryMode {
449 Off,
451 #[default]
453 Auto,
454 ApprovalRequired,
456}
457
458impl TelemetryMode {
459 pub fn is_enabled(&self) -> bool {
461 !matches!(self, TelemetryMode::Off)
462 }
463}
464
465#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
467#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
468#[serde(rename_all = "kebab-case")]
469pub enum HeartbeatsMode {
470 Off,
472 #[default]
474 On,
475}
476
477impl HeartbeatsMode {
478 pub fn is_enabled(&self) -> bool {
480 matches!(self, HeartbeatsMode::On)
481 }
482}
483
484#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
486#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
487#[serde(rename_all = "camelCase")]
488pub enum EndpointAccess {
489 #[default]
491 Internet,
492 Private,
494}
495
496impl EndpointAccess {
497 pub fn as_str(self) -> &'static str {
499 match self {
500 Self::Internet => "internet",
501 Self::Private => "private",
502 }
503 }
504}
505
506#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
511#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
512#[serde(rename_all = "camelCase")]
513pub struct DomainSettings {
514 #[serde(default, skip_serializing_if = "Option::is_none")]
516 pub custom_domains: Option<HashMap<String, CustomDomainConfig>>,
517 #[serde(default, skip_serializing_if = "Option::is_none")]
523 pub public_endpoint_target: Option<PublicEndpointTargetSettings>,
524}
525
526#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
528#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
529#[serde(rename_all = "camelCase", tag = "mode")]
530pub enum PublicEndpointTargetSettings {
531 MachineAddresses,
533 #[serde(rename_all = "camelCase")]
535 LoadBalancer {
536 cname_target: String,
538 },
539}
540
541#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
543#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
544#[serde(rename_all = "camelCase")]
545pub struct CustomDomainConfig {
546 #[serde(default, skip_serializing_if = "String::is_empty")]
548 pub domain: String,
549 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
551 pub endpoints: HashMap<String, String>,
552 pub certificate: CustomCertificateConfig,
554}
555
556#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
558#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
559#[serde(rename_all = "camelCase")]
560pub struct CustomCertificateConfig {
561 #[serde(default, skip_serializing_if = "Option::is_none")]
563 pub managed: Option<ManagedCustomCertificateConfig>,
564 #[serde(default, skip_serializing_if = "Option::is_none")]
566 pub aws: Option<AwsCustomCertificateConfig>,
567 #[serde(default, skip_serializing_if = "Option::is_none")]
569 pub gcp: Option<GcpCustomCertificateConfig>,
570 #[serde(default, skip_serializing_if = "Option::is_none")]
572 pub azure: Option<AzureCustomCertificateConfig>,
573 #[serde(default, skip_serializing_if = "Option::is_none")]
575 pub kubernetes: Option<KubernetesCustomCertificateConfig>,
576}
577
578#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
580#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
581#[serde(rename_all = "camelCase", deny_unknown_fields)]
582pub struct ManagedCustomCertificateConfig {}
583
584#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
585#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
586#[serde(rename_all = "camelCase")]
587pub struct AwsCustomCertificateConfig {
588 pub certificate_arn: String,
589}
590
591#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
592#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
593#[serde(rename_all = "camelCase")]
594pub struct GcpCustomCertificateConfig {
595 pub certificate_name: String,
596}
597
598#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
599#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
600#[serde(rename_all = "camelCase")]
601pub struct AzureCustomCertificateConfig {
602 pub key_vault_certificate_id: String,
603 #[serde(default, skip_serializing_if = "Option::is_none")]
604 pub key_vault_resource_id: Option<String>,
605}
606
607#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
608#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
609#[serde(rename_all = "camelCase")]
610pub struct KubernetesCustomCertificateConfig {
611 pub tls_secret_ref: KubernetesTlsSecretRef,
613}
614
615#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
622#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
623#[serde(rename_all = "camelCase")]
624pub struct KubernetesSettings {
625 #[serde(default, skip_serializing_if = "Option::is_none")]
627 pub cluster: Option<KubernetesClusterSettings>,
628 #[serde(default, skip_serializing_if = "Option::is_none")]
630 pub exposure: Option<KubernetesExposureSettings>,
631}
632
633#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
635#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
636#[serde(rename_all = "camelCase")]
637pub struct KubernetesClusterSettings {
638 pub ownership: KubernetesClusterOwnership,
641 #[serde(default, skip_serializing_if = "Option::is_none")]
643 pub namespace: Option<String>,
644 #[serde(default, skip_serializing_if = "Option::is_none")]
646 pub cloud: Option<KubernetesCloudReference>,
647}
648
649#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
651#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
652#[serde(rename_all = "camelCase", tag = "mode")]
653pub enum KubernetesExposureSettings {
654 Disabled,
656 Generated {
658 route: KubernetesRouteProfile,
660 certificate: KubernetesCertificateMode,
662 },
663 Custom {
665 domain: String,
667 route: KubernetesRouteProfile,
669 certificate: KubernetesCertificateMode,
671 },
672 #[serde(rename_all = "camelCase")]
674 CustomBaseDomain {
675 base_domain: String,
677 route: KubernetesRouteProfile,
679 certificate: KubernetesCertificateMode,
681 },
682}
683
684#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
686#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
687#[serde(rename_all = "camelCase", tag = "routeApi")]
688pub enum KubernetesRouteProfile {
689 Ingress(KubernetesIngressRouteProfile),
691 Gateway(KubernetesGatewayRouteProfile),
693}
694
695#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
697#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
698#[serde(rename_all = "camelCase")]
699pub struct KubernetesIngressRouteProfile {
700 #[serde(default, skip_serializing_if = "Option::is_none")]
702 pub controller: Option<String>,
703 pub ingress_class_name: String,
705 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
707 pub labels: HashMap<String, String>,
708 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
710 pub annotations: HashMap<String, String>,
711 #[serde(default, skip_serializing_if = "Option::is_none")]
713 pub provider: Option<KubernetesRouteProviderOptions>,
714}
715
716#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
718#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
719#[serde(rename_all = "camelCase")]
720pub struct KubernetesGatewayRouteProfile {
721 #[serde(default, skip_serializing_if = "Option::is_none")]
723 pub controller: Option<String>,
724 pub gateway_class_name: String,
726 pub listener_port: u16,
728 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
730 pub labels: HashMap<String, String>,
731 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
733 pub annotations: HashMap<String, String>,
734 #[serde(default, skip_serializing_if = "Option::is_none")]
736 pub provider: Option<KubernetesRouteProviderOptions>,
737}
738
739#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
741#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
742#[serde(rename_all = "camelCase", tag = "provider")]
743pub enum KubernetesRouteProviderOptions {
744 #[serde(rename_all = "camelCase")]
746 AwsAlb {
747 scheme: String,
749 target_type: String,
751 #[serde(default, skip_serializing_if = "Option::is_none")]
753 ip_address_type: Option<String>,
754 #[serde(default, skip_serializing_if = "Vec::is_empty")]
756 subnet_ids: Vec<String>,
757 },
758 #[serde(rename_all = "camelCase")]
760 GkeGateway {
761 #[serde(default, skip_serializing_if = "Option::is_none")]
763 static_address_name: Option<String>,
764 },
765 #[serde(rename_all = "camelCase")]
767 AzureApplicationGatewayForContainers {
768 #[serde(default, skip_serializing_if = "Option::is_none")]
770 alb_namespace: Option<String>,
771 #[serde(default, skip_serializing_if = "Option::is_none")]
773 alb_name: Option<String>,
774 frontend: String,
776 },
777}
778
779#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
781#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
782#[serde(rename_all = "camelCase", tag = "mode")]
783pub enum KubernetesCertificateMode {
784 #[serde(rename_all = "camelCase")]
786 ManagedAcmImport {
787 #[serde(default, skip_serializing_if = "Option::is_none")]
789 region: Option<String>,
790 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
792 tags: HashMap<String, String>,
793 },
794 #[serde(rename_all = "camelCase")]
796 AwsAcmArn {
797 certificate_arn: String,
799 },
800 #[serde(rename_all = "camelCase")]
802 ManagedTlsSecret {
803 secret_name_template: String,
805 },
806 TlsSecretRef(KubernetesTlsSecretRef),
808 None,
810}
811
812#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
814#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
815#[serde(rename_all = "camelCase")]
816pub struct KubernetesTlsSecretRef {
817 pub secret_name: String,
819 #[serde(default, skip_serializing_if = "Option::is_none")]
821 pub namespace: Option<String>,
822}
823
824#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
833#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
834#[serde(rename_all = "camelCase")]
835pub struct StackSettings {
836 #[serde(default, skip_serializing_if = "is_manual_setup_management")]
839 pub setup_management: SetupManagement,
840 #[serde(default, skip_serializing_if = "Vec::is_empty")]
844 pub quota_warning_overrides: Vec<String>,
845
846 #[serde(default)]
849 pub endpoint_access: EndpointAccess,
850
851 #[serde(default, skip_serializing_if = "Option::is_none")]
858 pub network: Option<NetworkSettings>,
859
860 #[serde(default, skip_serializing_if = "Option::is_none")]
862 pub domains: Option<DomainSettings>,
863
864 #[serde(default, skip_serializing_if = "Option::is_none")]
866 pub kubernetes: Option<KubernetesSettings>,
867
868 #[serde(default, skip_serializing_if = "Option::is_none")]
874 pub compute: Option<ComputeSettings>,
875
876 #[serde(default, skip_serializing_if = "Option::is_none")]
882 #[cfg_attr(
883 feature = "openapi",
884 schema(value_type = Option<HashMap<String, HashMap<String, String>>>)
885 )]
886 pub public_endpoints: Option<crate::PublicEndpointUrls>,
887
888 #[serde(default, skip_serializing_if = "is_default_deployment_model")]
897 pub deployment_model: DeploymentModel,
898
899 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
902 pub airgapped: bool,
903
904 #[serde(default, skip_serializing_if = "is_default_updates_mode")]
908 pub updates: UpdatesMode,
909
910 #[serde(default, skip_serializing_if = "is_default_telemetry_mode")]
915 pub telemetry: TelemetryMode,
916
917 #[serde(default, skip_serializing_if = "is_default_heartbeats_mode")]
921 pub heartbeats: HeartbeatsMode,
922
923 #[serde(default, skip_serializing_if = "Option::is_none")]
928 pub external_bindings: Option<crate::ExternalBindings>,
929}
930
931#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
933#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
934#[serde(rename_all = "camelCase")]
935pub enum SetupManagement {
936 #[default]
938 Manual,
939 Automatic,
941}
942
943impl SetupManagement {
944 pub fn as_str(self) -> &'static str {
946 match self {
947 Self::Manual => "manual",
948 Self::Automatic => "automatic",
949 }
950 }
951}
952
953fn is_manual_setup_management(mode: &SetupManagement) -> bool {
954 *mode == SetupManagement::Manual
955}
956
957fn is_default_deployment_model(model: &DeploymentModel) -> bool {
958 *model == DeploymentModel::default()
959}
960
961fn is_default_updates_mode(mode: &UpdatesMode) -> bool {
962 *mode == UpdatesMode::default()
963}
964
965fn is_default_telemetry_mode(mode: &TelemetryMode) -> bool {
966 *mode == TelemetryMode::default()
967}
968
969fn is_default_heartbeats_mode(mode: &HeartbeatsMode) -> bool {
970 *mode == HeartbeatsMode::default()
971}
972
973#[cfg(test)]
974mod failure_domain_tests {
975 use super::*;
976
977 #[test]
978 fn old_compute_selection_deserializes_without_topology() {
979 let selection: ComputePoolSelection = serde_json::from_value(serde_json::json!({
980 "mode": "fixed",
981 "machines": 2,
982 "machine": "m7i.xlarge"
983 }))
984 .expect("existing selection should deserialize");
985 assert!(selection.failure_domains().is_none());
986 }
987
988 #[test]
989 fn rejects_duplicate_concrete_failure_domains() {
990 let selection = ComputePoolSelection::Fixed {
991 machines: 2,
992 machine: Some("m7i.xlarge".to_string()),
993 failure_domains: Some(FailureDomainSelection {
994 spread: 2,
995 selected_failure_domains: vec!["us-west-2a".to_string(); 2],
996 }),
997 };
998 assert_eq!(
999 selection.validate(),
1000 Err("selected failure domains must be unique".to_string())
1001 );
1002 }
1003
1004 #[test]
1005 fn fixed_pool_must_have_one_machine_per_failure_domain() {
1006 let invalid = ComputePoolSelection::Fixed {
1007 machines: 1,
1008 machine: None,
1009 failure_domains: Some(FailureDomainSelection {
1010 spread: 2,
1011 selected_failure_domains: Vec::new(),
1012 }),
1013 };
1014 assert_eq!(
1015 invalid.validate(),
1016 Err(
1017 "fixed compute pool machines (1) must be at least failure-domain spread (2)"
1018 .to_string()
1019 )
1020 );
1021
1022 let valid = ComputePoolSelection::Fixed {
1023 machines: 2,
1024 machine: None,
1025 failure_domains: Some(FailureDomainSelection {
1026 spread: 2,
1027 selected_failure_domains: Vec::new(),
1028 }),
1029 };
1030 assert_eq!(valid.validate(), Ok(()));
1031 }
1032
1033 #[test]
1034 fn autoscaling_pool_bounds_must_cover_every_failure_domain() {
1035 let invalid_max = ComputePoolSelection::Autoscale {
1036 min: 1,
1037 max: 1,
1038 machine: None,
1039 failure_domains: Some(FailureDomainSelection {
1040 spread: 2,
1041 selected_failure_domains: Vec::new(),
1042 }),
1043 };
1044 assert_eq!(
1045 invalid_max.validate(),
1046 Err(
1047 "autoscaling compute pool maximum (1) must be at least failure-domain spread (2)"
1048 .to_string()
1049 )
1050 );
1051
1052 let invalid_min = ComputePoolSelection::Autoscale {
1053 min: 1,
1054 max: 3,
1055 machine: None,
1056 failure_domains: Some(FailureDomainSelection {
1057 spread: 2,
1058 selected_failure_domains: Vec::new(),
1059 }),
1060 };
1061 assert_eq!(
1062 invalid_min.validate(),
1063 Err(
1064 "autoscaling compute pool minimum (1) must be at least failure-domain spread (2)"
1065 .to_string()
1066 )
1067 );
1068
1069 let valid = ComputePoolSelection::Autoscale {
1070 min: 2,
1071 max: 2,
1072 machine: None,
1073 failure_domains: Some(FailureDomainSelection {
1074 spread: 2,
1075 selected_failure_domains: Vec::new(),
1076 }),
1077 };
1078 assert_eq!(valid.validate(), Ok(()));
1079 }
1080
1081 #[test]
1082 fn machine_public_endpoints_round_trip_without_rewriting_urls() {
1083 let settings: StackSettings = serde_json::from_value(serde_json::json!({
1084 "publicEndpoints": {
1085 "loader": {
1086 "api": "https://loader.compute.example.com",
1087 "shares": "https://shares.compute.example.com",
1088 "webhooks": "https://webhooks.compute.example.com"
1089 }
1090 }
1091 }))
1092 .expect("stack settings should deserialize");
1093
1094 assert_eq!(
1095 serde_json::to_value(settings).expect("stack settings should serialize")
1096 ["publicEndpoints"]["loader"]["shares"],
1097 "https://shares.compute.example.com"
1098 );
1099 }
1100
1101 #[test]
1102 fn custom_domains_preserve_managed_endpoint_names_and_supplied_certificates() {
1103 for config in [
1104 serde_json::json!({
1105 "endpoints": {
1106 "api": "q7.m2x.example.com",
1107 "shares": "*.q7.s9t.example.com",
1108 "webhooks": "*.q7.w3h.example.com"
1109 },
1110 "certificate": {"managed": {}}
1111 }),
1112 serde_json::json!({
1113 "domain": "app.example.com",
1114 "certificate": {"aws": {"certificateArn": "arn:example"}}
1115 }),
1116 ] {
1117 let parsed: CustomDomainConfig = serde_json::from_value(config.clone())
1118 .expect("custom domain configuration should deserialize");
1119 assert_eq!(serde_json::to_value(parsed).unwrap(), config);
1120 }
1121 }
1122}