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 public_subnet_ids: Vec<String>,
122 private_subnet_ids: Vec<String>,
124 #[serde(default)]
126 security_group_ids: Vec<String>,
127 },
128
129 #[serde(rename = "byo-vpc-gcp")]
134 ByoVpcGcp {
135 network_name: String,
137 subnet_name: String,
139 region: String,
141 },
142
143 #[serde(rename = "byo-vnet-azure")]
148 ByoVnetAzure {
149 vnet_resource_id: String,
151 public_subnet_name: String,
153 private_subnet_name: String,
155 #[serde(default, skip_serializing_if = "Option::is_none")]
157 application_gateway_subnet_name: Option<String>,
158 #[serde(default, skip_serializing_if = "Option::is_none")]
164 private_endpoint_subnet_name: Option<String>,
165 },
166}
167
168fn default_availability_zones() -> u8 {
169 2
170}
171
172#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
178#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
179#[serde(rename_all = "camelCase")]
180pub struct ComputeSettings {
181 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
183 pub pools: HashMap<String, ComputePoolSelection>,
184 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
186 pub containers: HashMap<String, ContainerResourceSelection>,
187}
188
189#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
191#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
192#[serde(rename_all = "camelCase", deny_unknown_fields)]
193pub struct ContainerResourceSelection {
194 #[serde(
196 default,
197 skip_serializing_if = "Option::is_none",
198 deserialize_with = "deserialize_selected_cpu"
199 )]
200 #[cfg_attr(feature = "openapi", schema(value_type = Option<f64>))]
201 pub cpu: Option<serde_json::Number>,
202 #[serde(default, skip_serializing_if = "Option::is_none")]
204 pub memory: Option<String>,
205}
206
207fn deserialize_selected_cpu<'de, D: serde::Deserializer<'de>>(
208 deserializer: D,
209) -> std::result::Result<Option<serde_json::Number>, D::Error> {
210 match Option::<serde_json::Value>::deserialize(deserializer)? {
212 None => Ok(None),
213 Some(serde_json::Value::Number(number)) => Ok(Some(number)),
214 Some(serde_json::Value::String(number)) => {
215 number.parse().map(Some).map_err(serde::de::Error::custom)
216 }
217 Some(_) => Err(serde::de::Error::custom("CPU must be a number")),
218 }
219}
220
221#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
223#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
224#[serde(rename_all = "camelCase")]
225pub struct FailureDomainSelection {
226 pub spread: u8,
228 #[serde(default, skip_serializing_if = "Vec::is_empty")]
231 pub selected_failure_domains: Vec<String>,
232}
233
234#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
236#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
237#[serde(rename_all = "camelCase", tag = "mode")]
238pub enum ComputePoolSelection {
239 Fixed {
241 machines: u32,
243 #[serde(
245 default,
246 skip_serializing_if = "Option::is_none",
247 deserialize_with = "deserialize_machine_override"
248 )]
249 machine: Option<String>,
250 #[serde(default, skip_serializing_if = "Option::is_none")]
252 failure_domains: Option<FailureDomainSelection>,
253 },
254 Autoscale {
256 min: u32,
258 max: u32,
260 #[serde(
262 default,
263 skip_serializing_if = "Option::is_none",
264 deserialize_with = "deserialize_machine_override"
265 )]
266 machine: Option<String>,
267 #[serde(default, skip_serializing_if = "Option::is_none")]
269 failure_domains: Option<FailureDomainSelection>,
270 },
271}
272
273fn deserialize_machine_override<'de, D: serde::Deserializer<'de>>(
274 deserializer: D,
275) -> std::result::Result<Option<String>, D::Error> {
276 Ok(Option::<String>::deserialize(deserializer)?.filter(|machine| !machine.is_empty()))
278}
279
280impl ComputePoolSelection {
281 pub fn machine(&self) -> Option<&str> {
283 match self {
284 Self::Fixed { machine, .. } | Self::Autoscale { machine, .. } => machine.as_deref(),
285 }
286 }
287
288 pub fn failure_domains(&self) -> Option<&FailureDomainSelection> {
290 match self {
291 Self::Fixed {
292 failure_domains, ..
293 }
294 | Self::Autoscale {
295 failure_domains, ..
296 } => failure_domains.as_ref(),
297 }
298 }
299
300 pub fn min_size(&self) -> u32 {
302 match self {
303 Self::Fixed { machines, .. } => *machines,
304 Self::Autoscale { min, .. } => *min,
305 }
306 }
307
308 pub fn max_size(&self) -> u32 {
310 match self {
311 Self::Fixed { machines, .. } => *machines,
312 Self::Autoscale { max, .. } => *max,
313 }
314 }
315
316 pub fn validate(&self) -> std::result::Result<(), String> {
318 if self
319 .failure_domains()
320 .is_some_and(|selection| selection.spread == 0)
321 {
322 return Err("failure-domain spread must be at least one".to_string());
323 }
324 if self.failure_domains().is_some_and(|selection| {
325 !selection.selected_failure_domains.is_empty()
326 && selection.selected_failure_domains.len() != usize::from(selection.spread)
327 }) {
328 return Err("selected failure domains must match the requested spread".to_string());
329 }
330 if self.failure_domains().is_some_and(|selection| {
331 selection.selected_failure_domains.len()
332 != selection
333 .selected_failure_domains
334 .iter()
335 .collect::<std::collections::HashSet<_>>()
336 .len()
337 }) {
338 return Err("selected failure domains must be unique".to_string());
339 }
340 match self {
341 Self::Fixed { machines, .. } => {
342 if *machines == 0 {
343 return Err("fixed compute pools must select at least one machine".to_string());
344 }
345 if let Some(failure_domains) = self.failure_domains() {
346 if *machines < u32::from(failure_domains.spread) {
347 return Err(format!(
348 "fixed compute pool machines ({machines}) must be at least failure-domain spread ({})",
349 failure_domains.spread
350 ));
351 }
352 }
353 }
354 Self::Autoscale { min, max, .. } => {
355 if min > max {
356 return Err(format!(
357 "autoscaling compute pool minimum ({min}) cannot exceed maximum ({max})"
358 ));
359 }
360 if let Some(failure_domains) = self.failure_domains() {
361 let spread = u32::from(failure_domains.spread);
362 if *max < spread {
363 return Err(format!(
364 "autoscaling compute pool maximum ({max}) must be at least failure-domain spread ({spread})"
365 ));
366 }
367 if *min < spread {
368 return Err(format!(
369 "autoscaling compute pool minimum ({min}) must be at least failure-domain spread ({spread})"
370 ));
371 }
372 }
373 }
374 }
375 Ok(())
376 }
377}
378
379#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
381#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
382#[serde(rename_all = "camelCase")]
383pub enum DeploymentModel {
384 #[default]
387 Push,
388 Pull,
391}
392
393#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
395#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
396#[serde(rename_all = "kebab-case")]
397pub enum UpdatesMode {
398 #[default]
400 Auto,
401 ApprovalRequired,
403}
404
405#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
407#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
408#[serde(rename_all = "kebab-case")]
409pub enum TelemetryMode {
410 Off,
412 #[default]
414 Auto,
415 ApprovalRequired,
417}
418
419impl TelemetryMode {
420 pub fn is_enabled(&self) -> bool {
422 !matches!(self, TelemetryMode::Off)
423 }
424}
425
426#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
428#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
429#[serde(rename_all = "kebab-case")]
430pub enum HeartbeatsMode {
431 Off,
433 #[default]
435 On,
436}
437
438impl HeartbeatsMode {
439 pub fn is_enabled(&self) -> bool {
441 matches!(self, HeartbeatsMode::On)
442 }
443}
444
445#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
447#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
448#[serde(rename_all = "camelCase")]
449pub enum EndpointAccess {
450 #[default]
452 Internet,
453 Private,
455}
456
457impl EndpointAccess {
458 pub fn as_str(self) -> &'static str {
460 match self {
461 Self::Internet => "internet",
462 Self::Private => "private",
463 }
464 }
465}
466
467#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
472#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
473#[serde(rename_all = "camelCase")]
474pub struct DomainSettings {
475 #[serde(default, skip_serializing_if = "Option::is_none")]
477 pub custom_domains: Option<HashMap<String, CustomDomainConfig>>,
478 #[serde(default, skip_serializing_if = "Option::is_none")]
484 pub public_endpoint_target: Option<PublicEndpointTargetSettings>,
485}
486
487#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
489#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
490#[serde(rename_all = "camelCase", tag = "mode")]
491pub enum PublicEndpointTargetSettings {
492 MachineAddresses,
494 #[serde(rename_all = "camelCase")]
496 LoadBalancer {
497 cname_target: String,
499 },
500}
501
502#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
504#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
505#[serde(rename_all = "camelCase")]
506pub struct CustomDomainConfig {
507 pub domain: String,
509 pub certificate: CustomCertificateConfig,
511}
512
513#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
515#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
516#[serde(rename_all = "camelCase")]
517pub struct CustomCertificateConfig {
518 #[serde(default, skip_serializing_if = "Option::is_none")]
520 pub aws: Option<AwsCustomCertificateConfig>,
521 #[serde(default, skip_serializing_if = "Option::is_none")]
523 pub gcp: Option<GcpCustomCertificateConfig>,
524 #[serde(default, skip_serializing_if = "Option::is_none")]
526 pub azure: Option<AzureCustomCertificateConfig>,
527 #[serde(default, skip_serializing_if = "Option::is_none")]
529 pub kubernetes: Option<KubernetesCustomCertificateConfig>,
530}
531
532#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
533#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
534#[serde(rename_all = "camelCase")]
535pub struct AwsCustomCertificateConfig {
536 pub certificate_arn: String,
537}
538
539#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
540#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
541#[serde(rename_all = "camelCase")]
542pub struct GcpCustomCertificateConfig {
543 pub certificate_name: String,
544}
545
546#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
547#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
548#[serde(rename_all = "camelCase")]
549pub struct AzureCustomCertificateConfig {
550 pub key_vault_certificate_id: String,
551 #[serde(default, skip_serializing_if = "Option::is_none")]
552 pub key_vault_resource_id: Option<String>,
553}
554
555#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
556#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
557#[serde(rename_all = "camelCase")]
558pub struct KubernetesCustomCertificateConfig {
559 pub tls_secret_ref: KubernetesTlsSecretRef,
561}
562
563#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
570#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
571#[serde(rename_all = "camelCase")]
572pub struct KubernetesSettings {
573 #[serde(default, skip_serializing_if = "Option::is_none")]
575 pub cluster: Option<KubernetesClusterSettings>,
576 #[serde(default, skip_serializing_if = "Option::is_none")]
578 pub exposure: Option<KubernetesExposureSettings>,
579}
580
581#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
583#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
584#[serde(rename_all = "camelCase")]
585pub struct KubernetesClusterSettings {
586 pub ownership: KubernetesClusterOwnership,
589 #[serde(default, skip_serializing_if = "Option::is_none")]
591 pub namespace: Option<String>,
592 #[serde(default, skip_serializing_if = "Option::is_none")]
594 pub cloud: Option<KubernetesCloudReference>,
595}
596
597#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
599#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
600#[serde(rename_all = "camelCase", tag = "mode")]
601pub enum KubernetesExposureSettings {
602 Disabled,
604 Generated {
606 route: KubernetesRouteProfile,
608 certificate: KubernetesCertificateMode,
610 },
611 Custom {
613 domain: String,
615 route: KubernetesRouteProfile,
617 certificate: KubernetesCertificateMode,
619 },
620}
621
622#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
624#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
625#[serde(rename_all = "camelCase", tag = "routeApi")]
626pub enum KubernetesRouteProfile {
627 Ingress(KubernetesIngressRouteProfile),
629 Gateway(KubernetesGatewayRouteProfile),
631}
632
633#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
635#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
636#[serde(rename_all = "camelCase")]
637pub struct KubernetesIngressRouteProfile {
638 #[serde(default, skip_serializing_if = "Option::is_none")]
640 pub controller: Option<String>,
641 pub ingress_class_name: String,
643 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
645 pub labels: HashMap<String, String>,
646 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
648 pub annotations: HashMap<String, String>,
649 #[serde(default, skip_serializing_if = "Option::is_none")]
651 pub provider: Option<KubernetesRouteProviderOptions>,
652}
653
654#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
656#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
657#[serde(rename_all = "camelCase")]
658pub struct KubernetesGatewayRouteProfile {
659 #[serde(default, skip_serializing_if = "Option::is_none")]
661 pub controller: Option<String>,
662 pub gateway_class_name: String,
664 pub listener_port: u16,
666 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
668 pub labels: HashMap<String, String>,
669 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
671 pub annotations: HashMap<String, String>,
672 #[serde(default, skip_serializing_if = "Option::is_none")]
674 pub provider: Option<KubernetesRouteProviderOptions>,
675}
676
677#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
679#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
680#[serde(rename_all = "camelCase", tag = "provider")]
681pub enum KubernetesRouteProviderOptions {
682 #[serde(rename_all = "camelCase")]
684 AwsAlb {
685 scheme: String,
687 target_type: String,
689 #[serde(default, skip_serializing_if = "Option::is_none")]
691 ip_address_type: Option<String>,
692 #[serde(default, skip_serializing_if = "Vec::is_empty")]
694 subnet_ids: Vec<String>,
695 },
696 #[serde(rename_all = "camelCase")]
698 GkeGateway {
699 #[serde(default, skip_serializing_if = "Option::is_none")]
701 static_address_name: Option<String>,
702 },
703 #[serde(rename_all = "camelCase")]
705 AzureApplicationGatewayForContainers {
706 #[serde(default, skip_serializing_if = "Option::is_none")]
708 alb_namespace: Option<String>,
709 #[serde(default, skip_serializing_if = "Option::is_none")]
711 alb_name: Option<String>,
712 frontend: String,
714 },
715}
716
717#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
719#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
720#[serde(rename_all = "camelCase", tag = "mode")]
721pub enum KubernetesCertificateMode {
722 #[serde(rename_all = "camelCase")]
724 ManagedAcmImport {
725 #[serde(default, skip_serializing_if = "Option::is_none")]
727 region: Option<String>,
728 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
730 tags: HashMap<String, String>,
731 },
732 #[serde(rename_all = "camelCase")]
734 AwsAcmArn {
735 certificate_arn: String,
737 },
738 #[serde(rename_all = "camelCase")]
740 ManagedTlsSecret {
741 secret_name_template: String,
743 },
744 TlsSecretRef(KubernetesTlsSecretRef),
746 None,
748}
749
750#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
752#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
753#[serde(rename_all = "camelCase")]
754pub struct KubernetesTlsSecretRef {
755 pub secret_name: String,
757 #[serde(default, skip_serializing_if = "Option::is_none")]
759 pub namespace: Option<String>,
760}
761
762#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
771#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
772#[serde(rename_all = "camelCase")]
773pub struct StackSettings {
774 #[serde(default)]
777 pub endpoint_access: EndpointAccess,
778
779 #[serde(default, skip_serializing_if = "Option::is_none")]
786 pub network: Option<NetworkSettings>,
787
788 #[serde(default, skip_serializing_if = "Option::is_none")]
790 pub domains: Option<DomainSettings>,
791
792 #[serde(default, skip_serializing_if = "Option::is_none")]
794 pub kubernetes: Option<KubernetesSettings>,
795
796 #[serde(default, skip_serializing_if = "Option::is_none")]
802 pub compute: Option<ComputeSettings>,
803
804 #[serde(default, skip_serializing_if = "Option::is_none")]
810 #[cfg_attr(
811 feature = "openapi",
812 schema(value_type = Option<HashMap<String, HashMap<String, String>>>)
813 )]
814 pub public_endpoints: Option<crate::PublicEndpointUrls>,
815
816 #[serde(default, skip_serializing_if = "is_default_deployment_model")]
825 pub deployment_model: DeploymentModel,
826
827 #[serde(default, skip_serializing_if = "is_default_updates_mode")]
831 pub updates: UpdatesMode,
832
833 #[serde(default, skip_serializing_if = "is_default_telemetry_mode")]
838 pub telemetry: TelemetryMode,
839
840 #[serde(default, skip_serializing_if = "is_default_heartbeats_mode")]
844 pub heartbeats: HeartbeatsMode,
845
846 #[serde(default, skip_serializing_if = "Option::is_none")]
851 pub external_bindings: Option<crate::ExternalBindings>,
852}
853
854fn is_default_deployment_model(model: &DeploymentModel) -> bool {
855 *model == DeploymentModel::default()
856}
857
858fn is_default_updates_mode(mode: &UpdatesMode) -> bool {
859 *mode == UpdatesMode::default()
860}
861
862fn is_default_telemetry_mode(mode: &TelemetryMode) -> bool {
863 *mode == TelemetryMode::default()
864}
865
866fn is_default_heartbeats_mode(mode: &HeartbeatsMode) -> bool {
867 *mode == HeartbeatsMode::default()
868}
869
870#[cfg(test)]
871mod failure_domain_tests {
872 use super::*;
873
874 #[test]
875 fn old_compute_selection_deserializes_without_topology() {
876 let selection: ComputePoolSelection = serde_json::from_value(serde_json::json!({
877 "mode": "fixed",
878 "machines": 2,
879 "machine": "m7i.xlarge"
880 }))
881 .expect("existing selection should deserialize");
882 assert!(selection.failure_domains().is_none());
883 }
884
885 #[test]
886 fn rejects_duplicate_concrete_failure_domains() {
887 let selection = ComputePoolSelection::Fixed {
888 machines: 2,
889 machine: Some("m7i.xlarge".to_string()),
890 failure_domains: Some(FailureDomainSelection {
891 spread: 2,
892 selected_failure_domains: vec!["us-west-2a".to_string(); 2],
893 }),
894 };
895 assert_eq!(
896 selection.validate(),
897 Err("selected failure domains must be unique".to_string())
898 );
899 }
900
901 #[test]
902 fn fixed_pool_must_have_one_machine_per_failure_domain() {
903 let invalid = ComputePoolSelection::Fixed {
904 machines: 1,
905 machine: None,
906 failure_domains: Some(FailureDomainSelection {
907 spread: 2,
908 selected_failure_domains: Vec::new(),
909 }),
910 };
911 assert_eq!(
912 invalid.validate(),
913 Err(
914 "fixed compute pool machines (1) must be at least failure-domain spread (2)"
915 .to_string()
916 )
917 );
918
919 let valid = ComputePoolSelection::Fixed {
920 machines: 2,
921 machine: None,
922 failure_domains: Some(FailureDomainSelection {
923 spread: 2,
924 selected_failure_domains: Vec::new(),
925 }),
926 };
927 assert_eq!(valid.validate(), Ok(()));
928 }
929
930 #[test]
931 fn autoscaling_pool_bounds_must_cover_every_failure_domain() {
932 let invalid_max = ComputePoolSelection::Autoscale {
933 min: 1,
934 max: 1,
935 machine: None,
936 failure_domains: Some(FailureDomainSelection {
937 spread: 2,
938 selected_failure_domains: Vec::new(),
939 }),
940 };
941 assert_eq!(
942 invalid_max.validate(),
943 Err(
944 "autoscaling compute pool maximum (1) must be at least failure-domain spread (2)"
945 .to_string()
946 )
947 );
948
949 let invalid_min = ComputePoolSelection::Autoscale {
950 min: 1,
951 max: 3,
952 machine: None,
953 failure_domains: Some(FailureDomainSelection {
954 spread: 2,
955 selected_failure_domains: Vec::new(),
956 }),
957 };
958 assert_eq!(
959 invalid_min.validate(),
960 Err(
961 "autoscaling compute pool minimum (1) must be at least failure-domain spread (2)"
962 .to_string()
963 )
964 );
965
966 let valid = ComputePoolSelection::Autoscale {
967 min: 2,
968 max: 2,
969 machine: None,
970 failure_domains: Some(FailureDomainSelection {
971 spread: 2,
972 selected_failure_domains: Vec::new(),
973 }),
974 };
975 assert_eq!(valid.validate(), Ok(()));
976 }
977
978 #[test]
979 fn machine_public_endpoints_round_trip_without_rewriting_urls() {
980 let settings: StackSettings = serde_json::from_value(serde_json::json!({
981 "publicEndpoints": {
982 "loader": {
983 "api": "https://loader.compute.example.com",
984 "shares": "https://shares.compute.example.com",
985 "webhooks": "https://webhooks.compute.example.com"
986 }
987 }
988 }))
989 .expect("stack settings should deserialize");
990
991 assert_eq!(
992 serde_json::to_value(settings).expect("stack settings should serialize")
993 ["publicEndpoints"]["loader"]["shares"],
994 "https://shares.compute.example.com"
995 );
996 }
997}