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}
27
28#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
30#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
31#[serde(rename_all = "camelCase")]
32pub struct AzureManagementConfig {
33 pub managing_tenant_id: String,
35 pub oidc_issuer: String,
37 pub oidc_subject: String,
39}
40
41#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
46#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
47#[serde(rename_all = "camelCase", tag = "platform")]
48pub enum ManagementConfig {
49 Aws(AwsManagementConfig),
51 Gcp(GcpManagementConfig),
53 Azure(AzureManagementConfig),
55 Kubernetes,
57}
58
59#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
75#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
76#[serde(rename_all = "camelCase", tag = "type")]
77pub enum NetworkSettings {
78 #[serde(rename = "use-default")]
92 UseDefault,
93
94 #[serde(rename = "create")]
101 Create {
102 #[serde(skip_serializing_if = "Option::is_none")]
105 cidr: Option<String>,
106
107 #[serde(default = "default_availability_zones")]
109 availability_zones: u8,
110 },
111
112 #[serde(rename = "byo-vpc-aws")]
117 ByoVpcAws {
118 vpc_id: String,
120 #[serde(deserialize_with = "crate::network_ids::deserialize_ids")]
122 public_subnet_ids: Vec<String>,
123 #[serde(deserialize_with = "crate::network_ids::deserialize_ids")]
125 private_subnet_ids: Vec<String>,
126 #[serde(default)]
128 #[serde(deserialize_with = "crate::network_ids::deserialize_ids")]
129 security_group_ids: Vec<String>,
130 },
131
132 #[serde(rename = "byo-vpc-gcp")]
137 ByoVpcGcp {
138 network_name: String,
140 subnet_name: String,
142 region: String,
144 },
145
146 #[serde(rename = "byo-vnet-azure")]
151 ByoVnetAzure {
152 vnet_resource_id: String,
154 public_subnet_name: String,
156 private_subnet_name: String,
158 #[serde(default, skip_serializing_if = "Option::is_none")]
160 application_gateway_subnet_name: Option<String>,
161 #[serde(default, skip_serializing_if = "Option::is_none")]
167 private_endpoint_subnet_name: Option<String>,
168 },
169}
170
171fn default_availability_zones() -> u8 {
172 2
173}
174
175#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
181#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
182#[serde(rename_all = "camelCase")]
183pub struct ComputeSettings {
184 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
186 pub pools: HashMap<String, ComputePoolSelection>,
187 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
189 pub containers: HashMap<String, ContainerResourceSelection>,
190}
191
192#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
194#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
195#[serde(rename_all = "camelCase", deny_unknown_fields)]
196pub struct ContainerResourceSelection {
197 #[serde(
199 default,
200 skip_serializing_if = "Option::is_none",
201 deserialize_with = "deserialize_selected_cpu"
202 )]
203 #[cfg_attr(feature = "openapi", schema(value_type = Option<f64>))]
204 pub cpu: Option<serde_json::Number>,
205 #[serde(default, skip_serializing_if = "Option::is_none")]
207 pub memory: Option<String>,
208 #[serde(default, skip_serializing_if = "Option::is_none")]
211 #[serde(deserialize_with = "deserialize_selected_volume_size")]
212 pub volume_size_gib: Option<u32>,
213}
214
215fn deserialize_selected_volume_size<'de, D: serde::Deserializer<'de>>(
216 deserializer: D,
217) -> std::result::Result<Option<u32>, D::Error> {
218 match Option::<serde_json::Value>::deserialize(deserializer)? {
220 None => Ok(None),
221 Some(serde_json::Value::Number(number)) => number
222 .as_u64()
223 .and_then(|number| u32::try_from(number).ok())
224 .map(Some)
225 .ok_or_else(|| {
226 serde::de::Error::custom("volumeSizeGib must be a whole positive capacity")
227 }),
228 Some(serde_json::Value::String(number)) => {
229 number.parse().map(Some).map_err(serde::de::Error::custom)
230 }
231 Some(_) => Err(serde::de::Error::custom(
232 "volumeSizeGib must be a whole positive capacity",
233 )),
234 }
235}
236
237fn deserialize_selected_cpu<'de, D: serde::Deserializer<'de>>(
238 deserializer: D,
239) -> std::result::Result<Option<serde_json::Number>, D::Error> {
240 match Option::<serde_json::Value>::deserialize(deserializer)? {
242 None => Ok(None),
243 Some(serde_json::Value::Number(number)) => Ok(Some(number)),
244 Some(serde_json::Value::String(number)) => {
245 number.parse().map(Some).map_err(serde::de::Error::custom)
246 }
247 Some(_) => Err(serde::de::Error::custom("CPU must be a number")),
248 }
249}
250
251#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
253#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
254#[serde(rename_all = "camelCase")]
255pub struct FailureDomainSelection {
256 pub spread: u8,
258 #[serde(default, skip_serializing_if = "Vec::is_empty")]
261 pub selected_failure_domains: Vec<String>,
262}
263
264#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
266#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
267#[serde(rename_all = "camelCase", tag = "mode")]
268pub enum ComputePoolSelection {
269 Fixed {
271 machines: u32,
273 #[serde(
275 default,
276 skip_serializing_if = "Option::is_none",
277 deserialize_with = "deserialize_machine_override"
278 )]
279 machine: Option<String>,
280 #[serde(default, skip_serializing_if = "Option::is_none")]
282 failure_domains: Option<FailureDomainSelection>,
283 },
284 Autoscale {
286 min: u32,
288 max: u32,
290 #[serde(
292 default,
293 skip_serializing_if = "Option::is_none",
294 deserialize_with = "deserialize_machine_override"
295 )]
296 machine: Option<String>,
297 #[serde(default, skip_serializing_if = "Option::is_none")]
299 failure_domains: Option<FailureDomainSelection>,
300 },
301}
302
303fn deserialize_machine_override<'de, D: serde::Deserializer<'de>>(
304 deserializer: D,
305) -> std::result::Result<Option<String>, D::Error> {
306 Ok(Option::<String>::deserialize(deserializer)?.filter(|machine| !machine.is_empty()))
308}
309
310impl ComputePoolSelection {
311 pub fn machine(&self) -> Option<&str> {
313 match self {
314 Self::Fixed { machine, .. } | Self::Autoscale { machine, .. } => machine.as_deref(),
315 }
316 }
317
318 pub fn failure_domains(&self) -> Option<&FailureDomainSelection> {
320 match self {
321 Self::Fixed {
322 failure_domains, ..
323 }
324 | Self::Autoscale {
325 failure_domains, ..
326 } => failure_domains.as_ref(),
327 }
328 }
329
330 pub fn min_size(&self) -> u32 {
332 match self {
333 Self::Fixed { machines, .. } => *machines,
334 Self::Autoscale { min, .. } => *min,
335 }
336 }
337
338 pub fn max_size(&self) -> u32 {
340 match self {
341 Self::Fixed { machines, .. } => *machines,
342 Self::Autoscale { max, .. } => *max,
343 }
344 }
345
346 pub fn validate(&self) -> std::result::Result<(), String> {
348 if self
349 .failure_domains()
350 .is_some_and(|selection| selection.spread == 0)
351 {
352 return Err("failure-domain spread must be at least one".to_string());
353 }
354 if self.failure_domains().is_some_and(|selection| {
355 !selection.selected_failure_domains.is_empty()
356 && selection.selected_failure_domains.len() != usize::from(selection.spread)
357 }) {
358 return Err("selected failure domains must match the requested spread".to_string());
359 }
360 if self.failure_domains().is_some_and(|selection| {
361 selection.selected_failure_domains.len()
362 != selection
363 .selected_failure_domains
364 .iter()
365 .collect::<std::collections::HashSet<_>>()
366 .len()
367 }) {
368 return Err("selected failure domains must be unique".to_string());
369 }
370 match self {
371 Self::Fixed { machines, .. } => {
372 if *machines == 0 {
373 return Err("fixed compute pools must select at least one machine".to_string());
374 }
375 if let Some(failure_domains) = self.failure_domains() {
376 if *machines < u32::from(failure_domains.spread) {
377 return Err(format!(
378 "fixed compute pool machines ({machines}) must be at least failure-domain spread ({})",
379 failure_domains.spread
380 ));
381 }
382 }
383 }
384 Self::Autoscale { min, max, .. } => {
385 if min > max {
386 return Err(format!(
387 "autoscaling compute pool minimum ({min}) cannot exceed maximum ({max})"
388 ));
389 }
390 if let Some(failure_domains) = self.failure_domains() {
391 let spread = u32::from(failure_domains.spread);
392 if *max < spread {
393 return Err(format!(
394 "autoscaling compute pool maximum ({max}) must be at least failure-domain spread ({spread})"
395 ));
396 }
397 if *min < spread {
398 return Err(format!(
399 "autoscaling compute pool minimum ({min}) must be at least failure-domain spread ({spread})"
400 ));
401 }
402 }
403 }
404 }
405 Ok(())
406 }
407}
408
409#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
411#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
412#[serde(rename_all = "camelCase")]
413pub enum DeploymentModel {
414 #[default]
417 Push,
418 Pull,
421}
422
423#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
425#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
426#[serde(rename_all = "kebab-case")]
427pub enum UpdatesMode {
428 #[default]
430 Auto,
431 ApprovalRequired,
433}
434
435#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
437#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
438#[serde(rename_all = "kebab-case")]
439pub enum TelemetryMode {
440 Off,
442 #[default]
444 Auto,
445 ApprovalRequired,
447}
448
449impl TelemetryMode {
450 pub fn is_enabled(&self) -> bool {
452 !matches!(self, TelemetryMode::Off)
453 }
454}
455
456#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
458#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
459#[serde(rename_all = "kebab-case")]
460pub enum HeartbeatsMode {
461 Off,
463 #[default]
465 On,
466}
467
468impl HeartbeatsMode {
469 pub fn is_enabled(&self) -> bool {
471 matches!(self, HeartbeatsMode::On)
472 }
473}
474
475#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
477#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
478#[serde(rename_all = "camelCase")]
479pub enum EndpointAccess {
480 #[default]
482 Internet,
483 Private,
485}
486
487impl EndpointAccess {
488 pub fn as_str(self) -> &'static str {
490 match self {
491 Self::Internet => "internet",
492 Self::Private => "private",
493 }
494 }
495}
496
497#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
502#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
503#[serde(rename_all = "camelCase")]
504pub struct DomainSettings {
505 #[serde(default, skip_serializing_if = "Option::is_none")]
507 pub custom_domains: Option<HashMap<String, CustomDomainConfig>>,
508 #[serde(default, skip_serializing_if = "Option::is_none")]
514 pub public_endpoint_target: Option<PublicEndpointTargetSettings>,
515}
516
517#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
519#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
520#[serde(rename_all = "camelCase", tag = "mode")]
521pub enum PublicEndpointTargetSettings {
522 MachineAddresses,
524 #[serde(rename_all = "camelCase")]
526 LoadBalancer {
527 cname_target: String,
529 },
530}
531
532#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
534#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
535#[serde(rename_all = "camelCase")]
536pub struct CustomDomainConfig {
537 pub domain: String,
539 pub certificate: CustomCertificateConfig,
541}
542
543#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
545#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
546#[serde(rename_all = "camelCase")]
547pub struct CustomCertificateConfig {
548 #[serde(default, skip_serializing_if = "Option::is_none")]
550 pub aws: Option<AwsCustomCertificateConfig>,
551 #[serde(default, skip_serializing_if = "Option::is_none")]
553 pub gcp: Option<GcpCustomCertificateConfig>,
554 #[serde(default, skip_serializing_if = "Option::is_none")]
556 pub azure: Option<AzureCustomCertificateConfig>,
557 #[serde(default, skip_serializing_if = "Option::is_none")]
559 pub kubernetes: Option<KubernetesCustomCertificateConfig>,
560}
561
562#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
563#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
564#[serde(rename_all = "camelCase")]
565pub struct AwsCustomCertificateConfig {
566 pub certificate_arn: String,
567}
568
569#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
570#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
571#[serde(rename_all = "camelCase")]
572pub struct GcpCustomCertificateConfig {
573 pub certificate_name: String,
574}
575
576#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
577#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
578#[serde(rename_all = "camelCase")]
579pub struct AzureCustomCertificateConfig {
580 pub key_vault_certificate_id: String,
581 #[serde(default, skip_serializing_if = "Option::is_none")]
582 pub key_vault_resource_id: Option<String>,
583}
584
585#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
586#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
587#[serde(rename_all = "camelCase")]
588pub struct KubernetesCustomCertificateConfig {
589 pub tls_secret_ref: KubernetesTlsSecretRef,
591}
592
593#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
600#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
601#[serde(rename_all = "camelCase")]
602pub struct KubernetesSettings {
603 #[serde(default, skip_serializing_if = "Option::is_none")]
605 pub cluster: Option<KubernetesClusterSettings>,
606 #[serde(default, skip_serializing_if = "Option::is_none")]
608 pub exposure: Option<KubernetesExposureSettings>,
609}
610
611#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
613#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
614#[serde(rename_all = "camelCase")]
615pub struct KubernetesClusterSettings {
616 pub ownership: KubernetesClusterOwnership,
619 #[serde(default, skip_serializing_if = "Option::is_none")]
621 pub namespace: Option<String>,
622 #[serde(default, skip_serializing_if = "Option::is_none")]
624 pub cloud: Option<KubernetesCloudReference>,
625}
626
627#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
629#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
630#[serde(rename_all = "camelCase", tag = "mode")]
631pub enum KubernetesExposureSettings {
632 Disabled,
634 Generated {
636 route: KubernetesRouteProfile,
638 certificate: KubernetesCertificateMode,
640 },
641 Custom {
643 domain: String,
645 route: KubernetesRouteProfile,
647 certificate: KubernetesCertificateMode,
649 },
650}
651
652#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
654#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
655#[serde(rename_all = "camelCase", tag = "routeApi")]
656pub enum KubernetesRouteProfile {
657 Ingress(KubernetesIngressRouteProfile),
659 Gateway(KubernetesGatewayRouteProfile),
661}
662
663#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
665#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
666#[serde(rename_all = "camelCase")]
667pub struct KubernetesIngressRouteProfile {
668 #[serde(default, skip_serializing_if = "Option::is_none")]
670 pub controller: Option<String>,
671 pub ingress_class_name: String,
673 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
675 pub labels: HashMap<String, String>,
676 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
678 pub annotations: HashMap<String, String>,
679 #[serde(default, skip_serializing_if = "Option::is_none")]
681 pub provider: Option<KubernetesRouteProviderOptions>,
682}
683
684#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
686#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
687#[serde(rename_all = "camelCase")]
688pub struct KubernetesGatewayRouteProfile {
689 #[serde(default, skip_serializing_if = "Option::is_none")]
691 pub controller: Option<String>,
692 pub gateway_class_name: String,
694 pub listener_port: u16,
696 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
698 pub labels: HashMap<String, String>,
699 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
701 pub annotations: HashMap<String, String>,
702 #[serde(default, skip_serializing_if = "Option::is_none")]
704 pub provider: Option<KubernetesRouteProviderOptions>,
705}
706
707#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
709#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
710#[serde(rename_all = "camelCase", tag = "provider")]
711pub enum KubernetesRouteProviderOptions {
712 #[serde(rename_all = "camelCase")]
714 AwsAlb {
715 scheme: String,
717 target_type: String,
719 #[serde(default, skip_serializing_if = "Option::is_none")]
721 ip_address_type: Option<String>,
722 #[serde(default, skip_serializing_if = "Vec::is_empty")]
724 subnet_ids: Vec<String>,
725 },
726 #[serde(rename_all = "camelCase")]
728 GkeGateway {
729 #[serde(default, skip_serializing_if = "Option::is_none")]
731 static_address_name: Option<String>,
732 },
733 #[serde(rename_all = "camelCase")]
735 AzureApplicationGatewayForContainers {
736 #[serde(default, skip_serializing_if = "Option::is_none")]
738 alb_namespace: Option<String>,
739 #[serde(default, skip_serializing_if = "Option::is_none")]
741 alb_name: Option<String>,
742 frontend: String,
744 },
745}
746
747#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
749#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
750#[serde(rename_all = "camelCase", tag = "mode")]
751pub enum KubernetesCertificateMode {
752 #[serde(rename_all = "camelCase")]
754 ManagedAcmImport {
755 #[serde(default, skip_serializing_if = "Option::is_none")]
757 region: Option<String>,
758 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
760 tags: HashMap<String, String>,
761 },
762 #[serde(rename_all = "camelCase")]
764 AwsAcmArn {
765 certificate_arn: String,
767 },
768 #[serde(rename_all = "camelCase")]
770 ManagedTlsSecret {
771 secret_name_template: String,
773 },
774 TlsSecretRef(KubernetesTlsSecretRef),
776 None,
778}
779
780#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
782#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
783#[serde(rename_all = "camelCase")]
784pub struct KubernetesTlsSecretRef {
785 pub secret_name: String,
787 #[serde(default, skip_serializing_if = "Option::is_none")]
789 pub namespace: Option<String>,
790}
791
792#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
801#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
802#[serde(rename_all = "camelCase")]
803pub struct StackSettings {
804 #[serde(default, skip_serializing_if = "is_manual_setup_management")]
807 pub setup_management: SetupManagement,
808 #[serde(default, skip_serializing_if = "Vec::is_empty")]
812 pub quota_warning_overrides: Vec<String>,
813
814 #[serde(default)]
817 pub endpoint_access: EndpointAccess,
818
819 #[serde(default, skip_serializing_if = "Option::is_none")]
826 pub network: Option<NetworkSettings>,
827
828 #[serde(default, skip_serializing_if = "Option::is_none")]
830 pub domains: Option<DomainSettings>,
831
832 #[serde(default, skip_serializing_if = "Option::is_none")]
834 pub kubernetes: Option<KubernetesSettings>,
835
836 #[serde(default, skip_serializing_if = "Option::is_none")]
842 pub compute: Option<ComputeSettings>,
843
844 #[serde(default, skip_serializing_if = "Option::is_none")]
850 #[cfg_attr(
851 feature = "openapi",
852 schema(value_type = Option<HashMap<String, HashMap<String, String>>>)
853 )]
854 pub public_endpoints: Option<crate::PublicEndpointUrls>,
855
856 #[serde(default, skip_serializing_if = "is_default_deployment_model")]
865 pub deployment_model: DeploymentModel,
866
867 #[serde(default, skip_serializing_if = "is_default_updates_mode")]
871 pub updates: UpdatesMode,
872
873 #[serde(default, skip_serializing_if = "is_default_telemetry_mode")]
878 pub telemetry: TelemetryMode,
879
880 #[serde(default, skip_serializing_if = "is_default_heartbeats_mode")]
884 pub heartbeats: HeartbeatsMode,
885
886 #[serde(default, skip_serializing_if = "Option::is_none")]
891 pub external_bindings: Option<crate::ExternalBindings>,
892}
893
894#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
896#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
897#[serde(rename_all = "camelCase")]
898pub enum SetupManagement {
899 #[default]
901 Manual,
902 Automatic,
904}
905
906fn is_manual_setup_management(mode: &SetupManagement) -> bool {
907 *mode == SetupManagement::Manual
908}
909
910fn is_default_deployment_model(model: &DeploymentModel) -> bool {
911 *model == DeploymentModel::default()
912}
913
914fn is_default_updates_mode(mode: &UpdatesMode) -> bool {
915 *mode == UpdatesMode::default()
916}
917
918fn is_default_telemetry_mode(mode: &TelemetryMode) -> bool {
919 *mode == TelemetryMode::default()
920}
921
922fn is_default_heartbeats_mode(mode: &HeartbeatsMode) -> bool {
923 *mode == HeartbeatsMode::default()
924}
925
926#[cfg(test)]
927mod failure_domain_tests {
928 use super::*;
929
930 #[test]
931 fn old_compute_selection_deserializes_without_topology() {
932 let selection: ComputePoolSelection = serde_json::from_value(serde_json::json!({
933 "mode": "fixed",
934 "machines": 2,
935 "machine": "m7i.xlarge"
936 }))
937 .expect("existing selection should deserialize");
938 assert!(selection.failure_domains().is_none());
939 }
940
941 #[test]
942 fn rejects_duplicate_concrete_failure_domains() {
943 let selection = ComputePoolSelection::Fixed {
944 machines: 2,
945 machine: Some("m7i.xlarge".to_string()),
946 failure_domains: Some(FailureDomainSelection {
947 spread: 2,
948 selected_failure_domains: vec!["us-west-2a".to_string(); 2],
949 }),
950 };
951 assert_eq!(
952 selection.validate(),
953 Err("selected failure domains must be unique".to_string())
954 );
955 }
956
957 #[test]
958 fn fixed_pool_must_have_one_machine_per_failure_domain() {
959 let invalid = ComputePoolSelection::Fixed {
960 machines: 1,
961 machine: None,
962 failure_domains: Some(FailureDomainSelection {
963 spread: 2,
964 selected_failure_domains: Vec::new(),
965 }),
966 };
967 assert_eq!(
968 invalid.validate(),
969 Err(
970 "fixed compute pool machines (1) must be at least failure-domain spread (2)"
971 .to_string()
972 )
973 );
974
975 let valid = ComputePoolSelection::Fixed {
976 machines: 2,
977 machine: None,
978 failure_domains: Some(FailureDomainSelection {
979 spread: 2,
980 selected_failure_domains: Vec::new(),
981 }),
982 };
983 assert_eq!(valid.validate(), Ok(()));
984 }
985
986 #[test]
987 fn autoscaling_pool_bounds_must_cover_every_failure_domain() {
988 let invalid_max = ComputePoolSelection::Autoscale {
989 min: 1,
990 max: 1,
991 machine: None,
992 failure_domains: Some(FailureDomainSelection {
993 spread: 2,
994 selected_failure_domains: Vec::new(),
995 }),
996 };
997 assert_eq!(
998 invalid_max.validate(),
999 Err(
1000 "autoscaling compute pool maximum (1) must be at least failure-domain spread (2)"
1001 .to_string()
1002 )
1003 );
1004
1005 let invalid_min = ComputePoolSelection::Autoscale {
1006 min: 1,
1007 max: 3,
1008 machine: None,
1009 failure_domains: Some(FailureDomainSelection {
1010 spread: 2,
1011 selected_failure_domains: Vec::new(),
1012 }),
1013 };
1014 assert_eq!(
1015 invalid_min.validate(),
1016 Err(
1017 "autoscaling compute pool minimum (1) must be at least failure-domain spread (2)"
1018 .to_string()
1019 )
1020 );
1021
1022 let valid = ComputePoolSelection::Autoscale {
1023 min: 2,
1024 max: 2,
1025 machine: None,
1026 failure_domains: Some(FailureDomainSelection {
1027 spread: 2,
1028 selected_failure_domains: Vec::new(),
1029 }),
1030 };
1031 assert_eq!(valid.validate(), Ok(()));
1032 }
1033
1034 #[test]
1035 fn machine_public_endpoints_round_trip_without_rewriting_urls() {
1036 let settings: StackSettings = serde_json::from_value(serde_json::json!({
1037 "publicEndpoints": {
1038 "loader": {
1039 "api": "https://loader.compute.example.com",
1040 "shares": "https://shares.compute.example.com",
1041 "webhooks": "https://webhooks.compute.example.com"
1042 }
1043 }
1044 }))
1045 .expect("stack settings should deserialize");
1046
1047 assert_eq!(
1048 serde_json::to_value(settings).expect("stack settings should serialize")
1049 ["publicEndpoints"]["loader"]["shares"],
1050 "https://shares.compute.example.com"
1051 );
1052 }
1053}