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
179#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
185#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
186#[serde(rename_all = "camelCase")]
187pub struct ComputeSettings {
188 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
190 pub pools: HashMap<String, ComputePoolSelection>,
191 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
193 pub containers: HashMap<String, ContainerResourceSelection>,
194}
195
196#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
198#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
199#[serde(rename_all = "camelCase", deny_unknown_fields)]
200pub struct ContainerResourceSelection {
201 #[serde(
203 default,
204 skip_serializing_if = "Option::is_none",
205 deserialize_with = "deserialize_selected_cpu"
206 )]
207 #[cfg_attr(feature = "openapi", schema(value_type = Option<f64>))]
208 pub cpu: Option<serde_json::Number>,
209 #[serde(default, skip_serializing_if = "Option::is_none")]
211 pub memory: Option<String>,
212 #[serde(default, skip_serializing_if = "Option::is_none")]
215 #[serde(deserialize_with = "deserialize_selected_volume_size")]
216 pub volume_size_gib: Option<u32>,
217}
218
219fn deserialize_selected_volume_size<'de, D: serde::Deserializer<'de>>(
220 deserializer: D,
221) -> std::result::Result<Option<u32>, D::Error> {
222 match Option::<serde_json::Value>::deserialize(deserializer)? {
224 None => Ok(None),
225 Some(serde_json::Value::Number(number)) => number
226 .as_u64()
227 .and_then(|number| u32::try_from(number).ok())
228 .map(Some)
229 .ok_or_else(|| {
230 serde::de::Error::custom("volumeSizeGib must be a whole positive capacity")
231 }),
232 Some(serde_json::Value::String(number)) => {
233 number.parse().map(Some).map_err(serde::de::Error::custom)
234 }
235 Some(_) => Err(serde::de::Error::custom(
236 "volumeSizeGib must be a whole positive capacity",
237 )),
238 }
239}
240
241fn deserialize_selected_cpu<'de, D: serde::Deserializer<'de>>(
242 deserializer: D,
243) -> std::result::Result<Option<serde_json::Number>, D::Error> {
244 match Option::<serde_json::Value>::deserialize(deserializer)? {
246 None => Ok(None),
247 Some(serde_json::Value::Number(number)) => Ok(Some(number)),
248 Some(serde_json::Value::String(number)) => {
249 number.parse().map(Some).map_err(serde::de::Error::custom)
250 }
251 Some(_) => Err(serde::de::Error::custom("CPU must be a number")),
252 }
253}
254
255#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
257#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
258#[serde(rename_all = "camelCase")]
259pub struct FailureDomainSelection {
260 pub spread: u8,
262 #[serde(default, skip_serializing_if = "Vec::is_empty")]
265 pub selected_failure_domains: Vec<String>,
266}
267
268#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
270#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
271#[serde(rename_all = "camelCase", tag = "mode")]
272pub enum ComputePoolSelection {
273 Fixed {
275 machines: u32,
277 #[serde(
279 default,
280 skip_serializing_if = "Option::is_none",
281 deserialize_with = "deserialize_machine_override"
282 )]
283 machine: Option<String>,
284 #[serde(default, skip_serializing_if = "Option::is_none")]
286 failure_domains: Option<FailureDomainSelection>,
287 },
288 Autoscale {
290 min: u32,
292 max: u32,
294 #[serde(
296 default,
297 skip_serializing_if = "Option::is_none",
298 deserialize_with = "deserialize_machine_override"
299 )]
300 machine: Option<String>,
301 #[serde(default, skip_serializing_if = "Option::is_none")]
303 failure_domains: Option<FailureDomainSelection>,
304 },
305}
306
307fn deserialize_machine_override<'de, D: serde::Deserializer<'de>>(
308 deserializer: D,
309) -> std::result::Result<Option<String>, D::Error> {
310 Ok(Option::<String>::deserialize(deserializer)?.filter(|machine| !machine.is_empty()))
312}
313
314impl ComputePoolSelection {
315 pub fn machine(&self) -> Option<&str> {
317 match self {
318 Self::Fixed { machine, .. } | Self::Autoscale { machine, .. } => machine.as_deref(),
319 }
320 }
321
322 pub fn failure_domains(&self) -> Option<&FailureDomainSelection> {
324 match self {
325 Self::Fixed {
326 failure_domains, ..
327 }
328 | Self::Autoscale {
329 failure_domains, ..
330 } => failure_domains.as_ref(),
331 }
332 }
333
334 pub fn min_size(&self) -> u32 {
336 match self {
337 Self::Fixed { machines, .. } => *machines,
338 Self::Autoscale { min, .. } => *min,
339 }
340 }
341
342 pub fn max_size(&self) -> u32 {
344 match self {
345 Self::Fixed { machines, .. } => *machines,
346 Self::Autoscale { max, .. } => *max,
347 }
348 }
349
350 pub fn validate(&self) -> std::result::Result<(), String> {
352 if self
353 .failure_domains()
354 .is_some_and(|selection| selection.spread == 0)
355 {
356 return Err("failure-domain spread must be at least one".to_string());
357 }
358 if self.failure_domains().is_some_and(|selection| {
359 !selection.selected_failure_domains.is_empty()
360 && selection.selected_failure_domains.len() != usize::from(selection.spread)
361 }) {
362 return Err("selected failure domains must match the requested spread".to_string());
363 }
364 if self.failure_domains().is_some_and(|selection| {
365 selection.selected_failure_domains.len()
366 != selection
367 .selected_failure_domains
368 .iter()
369 .collect::<std::collections::HashSet<_>>()
370 .len()
371 }) {
372 return Err("selected failure domains must be unique".to_string());
373 }
374 match self {
375 Self::Fixed { machines, .. } => {
376 if *machines == 0 {
377 return Err("fixed compute pools must select at least one machine".to_string());
378 }
379 if let Some(failure_domains) = self.failure_domains() {
380 if *machines < u32::from(failure_domains.spread) {
381 return Err(format!(
382 "fixed compute pool machines ({machines}) must be at least failure-domain spread ({})",
383 failure_domains.spread
384 ));
385 }
386 }
387 }
388 Self::Autoscale { min, max, .. } => {
389 if min > max {
390 return Err(format!(
391 "autoscaling compute pool minimum ({min}) cannot exceed maximum ({max})"
392 ));
393 }
394 if let Some(failure_domains) = self.failure_domains() {
395 let spread = u32::from(failure_domains.spread);
396 if *max < spread {
397 return Err(format!(
398 "autoscaling compute pool maximum ({max}) must be at least failure-domain spread ({spread})"
399 ));
400 }
401 if *min < spread {
402 return Err(format!(
403 "autoscaling compute pool minimum ({min}) must be at least failure-domain spread ({spread})"
404 ));
405 }
406 }
407 }
408 }
409 Ok(())
410 }
411}
412
413#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
415#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
416#[serde(rename_all = "camelCase")]
417pub enum DeploymentModel {
418 #[default]
421 Push,
422 Pull,
425}
426
427#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
429#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
430#[serde(rename_all = "kebab-case")]
431pub enum UpdatesMode {
432 #[default]
434 Auto,
435 ApprovalRequired,
437}
438
439#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
441#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
442#[serde(rename_all = "kebab-case")]
443pub enum TelemetryMode {
444 Off,
446 #[default]
448 Auto,
449 ApprovalRequired,
451}
452
453impl TelemetryMode {
454 pub fn is_enabled(&self) -> bool {
456 !matches!(self, TelemetryMode::Off)
457 }
458}
459
460#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
462#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
463#[serde(rename_all = "kebab-case")]
464pub enum HeartbeatsMode {
465 Off,
467 #[default]
469 On,
470}
471
472impl HeartbeatsMode {
473 pub fn is_enabled(&self) -> bool {
475 matches!(self, HeartbeatsMode::On)
476 }
477}
478
479#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
481#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
482#[serde(rename_all = "camelCase")]
483pub enum EndpointAccess {
484 #[default]
486 Internet,
487 Private,
489}
490
491impl EndpointAccess {
492 pub fn as_str(self) -> &'static str {
494 match self {
495 Self::Internet => "internet",
496 Self::Private => "private",
497 }
498 }
499}
500
501#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
506#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
507#[serde(rename_all = "camelCase")]
508pub struct DomainSettings {
509 #[serde(default, skip_serializing_if = "Option::is_none")]
511 pub custom_domains: Option<HashMap<String, CustomDomainConfig>>,
512 #[serde(default, skip_serializing_if = "Option::is_none")]
518 pub public_endpoint_target: Option<PublicEndpointTargetSettings>,
519}
520
521#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
523#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
524#[serde(rename_all = "camelCase", tag = "mode")]
525pub enum PublicEndpointTargetSettings {
526 MachineAddresses,
528 #[serde(rename_all = "camelCase")]
530 LoadBalancer {
531 cname_target: String,
533 },
534}
535
536#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
538#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
539#[serde(rename_all = "camelCase")]
540pub struct CustomDomainConfig {
541 #[serde(default, skip_serializing_if = "String::is_empty")]
543 pub domain: String,
544 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
546 pub endpoints: HashMap<String, String>,
547 pub certificate: CustomCertificateConfig,
549}
550
551#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
553#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
554#[serde(rename_all = "camelCase")]
555pub struct CustomCertificateConfig {
556 #[serde(default, skip_serializing_if = "Option::is_none")]
558 pub managed: Option<ManagedCustomCertificateConfig>,
559 #[serde(default, skip_serializing_if = "Option::is_none")]
561 pub aws: Option<AwsCustomCertificateConfig>,
562 #[serde(default, skip_serializing_if = "Option::is_none")]
564 pub gcp: Option<GcpCustomCertificateConfig>,
565 #[serde(default, skip_serializing_if = "Option::is_none")]
567 pub azure: Option<AzureCustomCertificateConfig>,
568 #[serde(default, skip_serializing_if = "Option::is_none")]
570 pub kubernetes: Option<KubernetesCustomCertificateConfig>,
571}
572
573#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
575#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
576#[serde(rename_all = "camelCase", deny_unknown_fields)]
577pub struct ManagedCustomCertificateConfig {}
578
579#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
580#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
581#[serde(rename_all = "camelCase")]
582pub struct AwsCustomCertificateConfig {
583 pub certificate_arn: String,
584}
585
586#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
587#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
588#[serde(rename_all = "camelCase")]
589pub struct GcpCustomCertificateConfig {
590 pub certificate_name: String,
591}
592
593#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
594#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
595#[serde(rename_all = "camelCase")]
596pub struct AzureCustomCertificateConfig {
597 pub key_vault_certificate_id: String,
598 #[serde(default, skip_serializing_if = "Option::is_none")]
599 pub key_vault_resource_id: Option<String>,
600}
601
602#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
603#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
604#[serde(rename_all = "camelCase")]
605pub struct KubernetesCustomCertificateConfig {
606 pub tls_secret_ref: KubernetesTlsSecretRef,
608}
609
610#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
617#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
618#[serde(rename_all = "camelCase")]
619pub struct KubernetesSettings {
620 #[serde(default, skip_serializing_if = "Option::is_none")]
622 pub cluster: Option<KubernetesClusterSettings>,
623 #[serde(default, skip_serializing_if = "Option::is_none")]
625 pub exposure: Option<KubernetesExposureSettings>,
626}
627
628#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
630#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
631#[serde(rename_all = "camelCase")]
632pub struct KubernetesClusterSettings {
633 pub ownership: KubernetesClusterOwnership,
636 #[serde(default, skip_serializing_if = "Option::is_none")]
638 pub namespace: Option<String>,
639 #[serde(default, skip_serializing_if = "Option::is_none")]
641 pub cloud: Option<KubernetesCloudReference>,
642}
643
644#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
646#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
647#[serde(rename_all = "camelCase", tag = "mode")]
648pub enum KubernetesExposureSettings {
649 Disabled,
651 Generated {
653 route: KubernetesRouteProfile,
655 certificate: KubernetesCertificateMode,
657 },
658 Custom {
660 domain: String,
662 route: KubernetesRouteProfile,
664 certificate: KubernetesCertificateMode,
666 },
667 #[serde(rename_all = "camelCase")]
669 CustomBaseDomain {
670 base_domain: String,
672 route: KubernetesRouteProfile,
674 certificate: KubernetesCertificateMode,
676 },
677}
678
679#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
681#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
682#[serde(rename_all = "camelCase", tag = "routeApi")]
683pub enum KubernetesRouteProfile {
684 Ingress(KubernetesIngressRouteProfile),
686 Gateway(KubernetesGatewayRouteProfile),
688}
689
690#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
692#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
693#[serde(rename_all = "camelCase")]
694pub struct KubernetesIngressRouteProfile {
695 #[serde(default, skip_serializing_if = "Option::is_none")]
697 pub controller: Option<String>,
698 pub ingress_class_name: String,
700 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
702 pub labels: HashMap<String, String>,
703 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
705 pub annotations: HashMap<String, String>,
706 #[serde(default, skip_serializing_if = "Option::is_none")]
708 pub provider: Option<KubernetesRouteProviderOptions>,
709}
710
711#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
713#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
714#[serde(rename_all = "camelCase")]
715pub struct KubernetesGatewayRouteProfile {
716 #[serde(default, skip_serializing_if = "Option::is_none")]
718 pub controller: Option<String>,
719 pub gateway_class_name: String,
721 pub listener_port: u16,
723 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
725 pub labels: HashMap<String, String>,
726 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
728 pub annotations: HashMap<String, String>,
729 #[serde(default, skip_serializing_if = "Option::is_none")]
731 pub provider: Option<KubernetesRouteProviderOptions>,
732}
733
734#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
736#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
737#[serde(rename_all = "camelCase", tag = "provider")]
738pub enum KubernetesRouteProviderOptions {
739 #[serde(rename_all = "camelCase")]
741 AwsAlb {
742 scheme: String,
744 target_type: String,
746 #[serde(default, skip_serializing_if = "Option::is_none")]
748 ip_address_type: Option<String>,
749 #[serde(default, skip_serializing_if = "Vec::is_empty")]
751 subnet_ids: Vec<String>,
752 },
753 #[serde(rename_all = "camelCase")]
755 GkeGateway {
756 #[serde(default, skip_serializing_if = "Option::is_none")]
758 static_address_name: Option<String>,
759 },
760 #[serde(rename_all = "camelCase")]
762 AzureApplicationGatewayForContainers {
763 #[serde(default, skip_serializing_if = "Option::is_none")]
765 alb_namespace: Option<String>,
766 #[serde(default, skip_serializing_if = "Option::is_none")]
768 alb_name: Option<String>,
769 frontend: String,
771 },
772}
773
774#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
776#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
777#[serde(rename_all = "camelCase", tag = "mode")]
778pub enum KubernetesCertificateMode {
779 #[serde(rename_all = "camelCase")]
781 ManagedAcmImport {
782 #[serde(default, skip_serializing_if = "Option::is_none")]
784 region: Option<String>,
785 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
787 tags: HashMap<String, String>,
788 },
789 #[serde(rename_all = "camelCase")]
791 AwsAcmArn {
792 certificate_arn: String,
794 },
795 #[serde(rename_all = "camelCase")]
797 ManagedTlsSecret {
798 secret_name_template: String,
800 },
801 TlsSecretRef(KubernetesTlsSecretRef),
803 None,
805}
806
807#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
809#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
810#[serde(rename_all = "camelCase")]
811pub struct KubernetesTlsSecretRef {
812 pub secret_name: String,
814 #[serde(default, skip_serializing_if = "Option::is_none")]
816 pub namespace: Option<String>,
817}
818
819#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
828#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
829#[serde(rename_all = "camelCase")]
830pub struct StackSettings {
831 #[serde(default, skip_serializing_if = "is_manual_setup_management")]
834 pub setup_management: SetupManagement,
835 #[serde(default, skip_serializing_if = "Vec::is_empty")]
839 pub quota_warning_overrides: Vec<String>,
840
841 #[serde(default)]
844 pub endpoint_access: EndpointAccess,
845
846 #[serde(default, skip_serializing_if = "Option::is_none")]
853 pub network: Option<NetworkSettings>,
854
855 #[serde(default, skip_serializing_if = "Option::is_none")]
857 pub domains: Option<DomainSettings>,
858
859 #[serde(default, skip_serializing_if = "Option::is_none")]
861 pub kubernetes: Option<KubernetesSettings>,
862
863 #[serde(default, skip_serializing_if = "Option::is_none")]
869 pub compute: Option<ComputeSettings>,
870
871 #[serde(default, skip_serializing_if = "Option::is_none")]
877 #[cfg_attr(
878 feature = "openapi",
879 schema(value_type = Option<HashMap<String, HashMap<String, String>>>)
880 )]
881 pub public_endpoints: Option<crate::PublicEndpointUrls>,
882
883 #[serde(default, skip_serializing_if = "is_default_deployment_model")]
892 pub deployment_model: DeploymentModel,
893
894 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
897 pub airgapped: bool,
898
899 #[serde(default, skip_serializing_if = "is_default_updates_mode")]
903 pub updates: UpdatesMode,
904
905 #[serde(default, skip_serializing_if = "is_default_telemetry_mode")]
910 pub telemetry: TelemetryMode,
911
912 #[serde(default, skip_serializing_if = "is_default_heartbeats_mode")]
916 pub heartbeats: HeartbeatsMode,
917
918 #[serde(default, skip_serializing_if = "Option::is_none")]
923 pub external_bindings: Option<crate::ExternalBindings>,
924}
925
926#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
928#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
929#[serde(rename_all = "camelCase")]
930pub enum SetupManagement {
931 #[default]
933 Manual,
934 Automatic,
936}
937
938impl SetupManagement {
939 pub fn as_str(self) -> &'static str {
941 match self {
942 Self::Manual => "manual",
943 Self::Automatic => "automatic",
944 }
945 }
946}
947
948fn is_manual_setup_management(mode: &SetupManagement) -> bool {
949 *mode == SetupManagement::Manual
950}
951
952fn is_default_deployment_model(model: &DeploymentModel) -> bool {
953 *model == DeploymentModel::default()
954}
955
956fn is_default_updates_mode(mode: &UpdatesMode) -> bool {
957 *mode == UpdatesMode::default()
958}
959
960fn is_default_telemetry_mode(mode: &TelemetryMode) -> bool {
961 *mode == TelemetryMode::default()
962}
963
964fn is_default_heartbeats_mode(mode: &HeartbeatsMode) -> bool {
965 *mode == HeartbeatsMode::default()
966}
967
968#[cfg(test)]
969mod failure_domain_tests {
970 use super::*;
971
972 #[test]
973 fn old_compute_selection_deserializes_without_topology() {
974 let selection: ComputePoolSelection = serde_json::from_value(serde_json::json!({
975 "mode": "fixed",
976 "machines": 2,
977 "machine": "m7i.xlarge"
978 }))
979 .expect("existing selection should deserialize");
980 assert!(selection.failure_domains().is_none());
981 }
982
983 #[test]
984 fn rejects_duplicate_concrete_failure_domains() {
985 let selection = ComputePoolSelection::Fixed {
986 machines: 2,
987 machine: Some("m7i.xlarge".to_string()),
988 failure_domains: Some(FailureDomainSelection {
989 spread: 2,
990 selected_failure_domains: vec!["us-west-2a".to_string(); 2],
991 }),
992 };
993 assert_eq!(
994 selection.validate(),
995 Err("selected failure domains must be unique".to_string())
996 );
997 }
998
999 #[test]
1000 fn fixed_pool_must_have_one_machine_per_failure_domain() {
1001 let invalid = ComputePoolSelection::Fixed {
1002 machines: 1,
1003 machine: None,
1004 failure_domains: Some(FailureDomainSelection {
1005 spread: 2,
1006 selected_failure_domains: Vec::new(),
1007 }),
1008 };
1009 assert_eq!(
1010 invalid.validate(),
1011 Err(
1012 "fixed compute pool machines (1) must be at least failure-domain spread (2)"
1013 .to_string()
1014 )
1015 );
1016
1017 let valid = ComputePoolSelection::Fixed {
1018 machines: 2,
1019 machine: None,
1020 failure_domains: Some(FailureDomainSelection {
1021 spread: 2,
1022 selected_failure_domains: Vec::new(),
1023 }),
1024 };
1025 assert_eq!(valid.validate(), Ok(()));
1026 }
1027
1028 #[test]
1029 fn autoscaling_pool_bounds_must_cover_every_failure_domain() {
1030 let invalid_max = ComputePoolSelection::Autoscale {
1031 min: 1,
1032 max: 1,
1033 machine: None,
1034 failure_domains: Some(FailureDomainSelection {
1035 spread: 2,
1036 selected_failure_domains: Vec::new(),
1037 }),
1038 };
1039 assert_eq!(
1040 invalid_max.validate(),
1041 Err(
1042 "autoscaling compute pool maximum (1) must be at least failure-domain spread (2)"
1043 .to_string()
1044 )
1045 );
1046
1047 let invalid_min = ComputePoolSelection::Autoscale {
1048 min: 1,
1049 max: 3,
1050 machine: None,
1051 failure_domains: Some(FailureDomainSelection {
1052 spread: 2,
1053 selected_failure_domains: Vec::new(),
1054 }),
1055 };
1056 assert_eq!(
1057 invalid_min.validate(),
1058 Err(
1059 "autoscaling compute pool minimum (1) must be at least failure-domain spread (2)"
1060 .to_string()
1061 )
1062 );
1063
1064 let valid = ComputePoolSelection::Autoscale {
1065 min: 2,
1066 max: 2,
1067 machine: None,
1068 failure_domains: Some(FailureDomainSelection {
1069 spread: 2,
1070 selected_failure_domains: Vec::new(),
1071 }),
1072 };
1073 assert_eq!(valid.validate(), Ok(()));
1074 }
1075
1076 #[test]
1077 fn machine_public_endpoints_round_trip_without_rewriting_urls() {
1078 let settings: StackSettings = serde_json::from_value(serde_json::json!({
1079 "publicEndpoints": {
1080 "loader": {
1081 "api": "https://loader.compute.example.com",
1082 "shares": "https://shares.compute.example.com",
1083 "webhooks": "https://webhooks.compute.example.com"
1084 }
1085 }
1086 }))
1087 .expect("stack settings should deserialize");
1088
1089 assert_eq!(
1090 serde_json::to_value(settings).expect("stack settings should serialize")
1091 ["publicEndpoints"]["loader"]["shares"],
1092 "https://shares.compute.example.com"
1093 );
1094 }
1095
1096 #[test]
1097 fn custom_domains_preserve_managed_endpoint_names_and_supplied_certificates() {
1098 for config in [
1099 serde_json::json!({
1100 "endpoints": {
1101 "api": "q7.m2x.example.com",
1102 "shares": "*.q7.s9t.example.com",
1103 "webhooks": "*.q7.w3h.example.com"
1104 },
1105 "certificate": {"managed": {}}
1106 }),
1107 serde_json::json!({
1108 "domain": "app.example.com",
1109 "certificate": {"aws": {"certificateArn": "arn:example"}}
1110 }),
1111 ] {
1112 let parsed: CustomDomainConfig = serde_json::from_value(config.clone())
1113 .expect("custom domain configuration should deserialize");
1114 assert_eq!(serde_json::to_value(parsed).unwrap(), config);
1115 }
1116 }
1117}