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}
185
186#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
188#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
189#[serde(rename_all = "camelCase")]
190pub struct FailureDomainSelection {
191 pub spread: u8,
193 #[serde(default, skip_serializing_if = "Vec::is_empty")]
196 pub selected_failure_domains: Vec<String>,
197}
198
199#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
201#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
202#[serde(rename_all = "camelCase", tag = "mode")]
203pub enum ComputePoolSelection {
204 Fixed {
206 machines: u32,
208 #[serde(default, skip_serializing_if = "Option::is_none")]
210 machine: Option<String>,
211 #[serde(default, skip_serializing_if = "Option::is_none")]
213 failure_domains: Option<FailureDomainSelection>,
214 },
215 Autoscale {
217 min: u32,
219 max: u32,
221 #[serde(default, skip_serializing_if = "Option::is_none")]
223 machine: Option<String>,
224 #[serde(default, skip_serializing_if = "Option::is_none")]
226 failure_domains: Option<FailureDomainSelection>,
227 },
228}
229
230impl ComputePoolSelection {
231 pub fn machine(&self) -> Option<&str> {
233 match self {
234 Self::Fixed { machine, .. } | Self::Autoscale { machine, .. } => machine.as_deref(),
235 }
236 }
237
238 pub fn failure_domains(&self) -> Option<&FailureDomainSelection> {
240 match self {
241 Self::Fixed {
242 failure_domains, ..
243 }
244 | Self::Autoscale {
245 failure_domains, ..
246 } => failure_domains.as_ref(),
247 }
248 }
249
250 pub fn min_size(&self) -> u32 {
252 match self {
253 Self::Fixed { machines, .. } => *machines,
254 Self::Autoscale { min, .. } => *min,
255 }
256 }
257
258 pub fn max_size(&self) -> u32 {
260 match self {
261 Self::Fixed { machines, .. } => *machines,
262 Self::Autoscale { max, .. } => *max,
263 }
264 }
265
266 pub fn validate(&self) -> std::result::Result<(), String> {
268 if self
269 .failure_domains()
270 .is_some_and(|selection| selection.spread == 0)
271 {
272 return Err("failure-domain spread must be at least one".to_string());
273 }
274 if self.failure_domains().is_some_and(|selection| {
275 !selection.selected_failure_domains.is_empty()
276 && selection.selected_failure_domains.len() != usize::from(selection.spread)
277 }) {
278 return Err("selected failure domains must match the requested spread".to_string());
279 }
280 if self.failure_domains().is_some_and(|selection| {
281 selection.selected_failure_domains.len()
282 != selection
283 .selected_failure_domains
284 .iter()
285 .collect::<std::collections::HashSet<_>>()
286 .len()
287 }) {
288 return Err("selected failure domains must be unique".to_string());
289 }
290 match self {
291 Self::Fixed { machines, .. } => {
292 if *machines == 0 {
293 return Err("fixed compute pools must select at least one machine".to_string());
294 }
295 if let Some(failure_domains) = self.failure_domains() {
296 if *machines < u32::from(failure_domains.spread) {
297 return Err(format!(
298 "fixed compute pool machines ({machines}) must be at least failure-domain spread ({})",
299 failure_domains.spread
300 ));
301 }
302 }
303 }
304 Self::Autoscale { min, max, .. } => {
305 if min > max {
306 return Err(format!(
307 "autoscaling compute pool minimum ({min}) cannot exceed maximum ({max})"
308 ));
309 }
310 if let Some(failure_domains) = self.failure_domains() {
311 let spread = u32::from(failure_domains.spread);
312 if *max < spread {
313 return Err(format!(
314 "autoscaling compute pool maximum ({max}) must be at least failure-domain spread ({spread})"
315 ));
316 }
317 if *min < spread {
318 return Err(format!(
319 "autoscaling compute pool minimum ({min}) must be at least failure-domain spread ({spread})"
320 ));
321 }
322 }
323 }
324 }
325 Ok(())
326 }
327}
328
329#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
331#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
332#[serde(rename_all = "camelCase")]
333pub enum DeploymentModel {
334 #[default]
337 Push,
338 Pull,
341}
342
343#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
345#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
346#[serde(rename_all = "kebab-case")]
347pub enum UpdatesMode {
348 #[default]
350 Auto,
351 ApprovalRequired,
353}
354
355#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
357#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
358#[serde(rename_all = "kebab-case")]
359pub enum TelemetryMode {
360 Off,
362 #[default]
364 Auto,
365 ApprovalRequired,
367}
368
369impl TelemetryMode {
370 pub fn is_enabled(&self) -> bool {
372 !matches!(self, TelemetryMode::Off)
373 }
374}
375
376#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
378#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
379#[serde(rename_all = "kebab-case")]
380pub enum HeartbeatsMode {
381 Off,
383 #[default]
385 On,
386}
387
388impl HeartbeatsMode {
389 pub fn is_enabled(&self) -> bool {
391 matches!(self, HeartbeatsMode::On)
392 }
393}
394
395#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
397#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
398#[serde(rename_all = "camelCase")]
399pub enum EndpointAccess {
400 #[default]
402 Internet,
403 Private,
405}
406
407impl EndpointAccess {
408 pub fn as_str(self) -> &'static str {
410 match self {
411 Self::Internet => "internet",
412 Self::Private => "private",
413 }
414 }
415}
416
417#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
422#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
423#[serde(rename_all = "camelCase")]
424pub struct DomainSettings {
425 #[serde(default, skip_serializing_if = "Option::is_none")]
427 pub custom_domains: Option<HashMap<String, CustomDomainConfig>>,
428 #[serde(default, skip_serializing_if = "Option::is_none")]
434 pub public_endpoint_target: Option<PublicEndpointTargetSettings>,
435}
436
437#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
439#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
440#[serde(rename_all = "camelCase", tag = "mode")]
441pub enum PublicEndpointTargetSettings {
442 MachineAddresses,
444 #[serde(rename_all = "camelCase")]
446 LoadBalancer {
447 cname_target: String,
449 },
450}
451
452#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
454#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
455#[serde(rename_all = "camelCase")]
456pub struct CustomDomainConfig {
457 pub domain: String,
459 pub certificate: CustomCertificateConfig,
461}
462
463#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
465#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
466#[serde(rename_all = "camelCase")]
467pub struct CustomCertificateConfig {
468 #[serde(default, skip_serializing_if = "Option::is_none")]
470 pub aws: Option<AwsCustomCertificateConfig>,
471 #[serde(default, skip_serializing_if = "Option::is_none")]
473 pub gcp: Option<GcpCustomCertificateConfig>,
474 #[serde(default, skip_serializing_if = "Option::is_none")]
476 pub azure: Option<AzureCustomCertificateConfig>,
477 #[serde(default, skip_serializing_if = "Option::is_none")]
479 pub kubernetes: Option<KubernetesCustomCertificateConfig>,
480}
481
482#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
483#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
484#[serde(rename_all = "camelCase")]
485pub struct AwsCustomCertificateConfig {
486 pub certificate_arn: String,
487}
488
489#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
490#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
491#[serde(rename_all = "camelCase")]
492pub struct GcpCustomCertificateConfig {
493 pub certificate_name: String,
494}
495
496#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
497#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
498#[serde(rename_all = "camelCase")]
499pub struct AzureCustomCertificateConfig {
500 pub key_vault_certificate_id: String,
501 #[serde(default, skip_serializing_if = "Option::is_none")]
502 pub key_vault_resource_id: Option<String>,
503}
504
505#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
506#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
507#[serde(rename_all = "camelCase")]
508pub struct KubernetesCustomCertificateConfig {
509 pub tls_secret_ref: KubernetesTlsSecretRef,
511}
512
513#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
520#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
521#[serde(rename_all = "camelCase")]
522pub struct KubernetesSettings {
523 #[serde(default, skip_serializing_if = "Option::is_none")]
525 pub cluster: Option<KubernetesClusterSettings>,
526 #[serde(default, skip_serializing_if = "Option::is_none")]
528 pub exposure: Option<KubernetesExposureSettings>,
529}
530
531#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
533#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
534#[serde(rename_all = "camelCase")]
535pub struct KubernetesClusterSettings {
536 pub ownership: KubernetesClusterOwnership,
539 #[serde(default, skip_serializing_if = "Option::is_none")]
541 pub namespace: Option<String>,
542 #[serde(default, skip_serializing_if = "Option::is_none")]
544 pub cloud: Option<KubernetesCloudReference>,
545}
546
547#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
549#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
550#[serde(rename_all = "camelCase", tag = "mode")]
551pub enum KubernetesExposureSettings {
552 Disabled,
554 Generated {
556 route: KubernetesRouteProfile,
558 certificate: KubernetesCertificateMode,
560 },
561 Custom {
563 domain: String,
565 route: KubernetesRouteProfile,
567 certificate: KubernetesCertificateMode,
569 },
570}
571
572#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
574#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
575#[serde(rename_all = "camelCase", tag = "routeApi")]
576pub enum KubernetesRouteProfile {
577 Ingress(KubernetesIngressRouteProfile),
579 Gateway(KubernetesGatewayRouteProfile),
581}
582
583#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
585#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
586#[serde(rename_all = "camelCase")]
587pub struct KubernetesIngressRouteProfile {
588 #[serde(default, skip_serializing_if = "Option::is_none")]
590 pub controller: Option<String>,
591 pub ingress_class_name: String,
593 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
595 pub labels: HashMap<String, String>,
596 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
598 pub annotations: HashMap<String, String>,
599 #[serde(default, skip_serializing_if = "Option::is_none")]
601 pub provider: Option<KubernetesRouteProviderOptions>,
602}
603
604#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
606#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
607#[serde(rename_all = "camelCase")]
608pub struct KubernetesGatewayRouteProfile {
609 #[serde(default, skip_serializing_if = "Option::is_none")]
611 pub controller: Option<String>,
612 pub gateway_class_name: String,
614 pub listener_port: u16,
616 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
618 pub labels: HashMap<String, String>,
619 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
621 pub annotations: HashMap<String, String>,
622 #[serde(default, skip_serializing_if = "Option::is_none")]
624 pub provider: Option<KubernetesRouteProviderOptions>,
625}
626
627#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
629#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
630#[serde(rename_all = "camelCase", tag = "provider")]
631pub enum KubernetesRouteProviderOptions {
632 #[serde(rename_all = "camelCase")]
634 AwsAlb {
635 scheme: String,
637 target_type: String,
639 #[serde(default, skip_serializing_if = "Option::is_none")]
641 ip_address_type: Option<String>,
642 #[serde(default, skip_serializing_if = "Vec::is_empty")]
644 subnet_ids: Vec<String>,
645 },
646 #[serde(rename_all = "camelCase")]
648 GkeGateway {
649 #[serde(default, skip_serializing_if = "Option::is_none")]
651 static_address_name: Option<String>,
652 },
653 #[serde(rename_all = "camelCase")]
655 AzureApplicationGatewayForContainers {
656 #[serde(default, skip_serializing_if = "Option::is_none")]
658 alb_namespace: Option<String>,
659 #[serde(default, skip_serializing_if = "Option::is_none")]
661 alb_name: Option<String>,
662 frontend: String,
664 },
665}
666
667#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
669#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
670#[serde(rename_all = "camelCase", tag = "mode")]
671pub enum KubernetesCertificateMode {
672 #[serde(rename_all = "camelCase")]
674 ManagedAcmImport {
675 #[serde(default, skip_serializing_if = "Option::is_none")]
677 region: Option<String>,
678 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
680 tags: HashMap<String, String>,
681 },
682 #[serde(rename_all = "camelCase")]
684 AwsAcmArn {
685 certificate_arn: String,
687 },
688 #[serde(rename_all = "camelCase")]
690 ManagedTlsSecret {
691 secret_name_template: String,
693 },
694 TlsSecretRef(KubernetesTlsSecretRef),
696 None,
698}
699
700#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
702#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
703#[serde(rename_all = "camelCase")]
704pub struct KubernetesTlsSecretRef {
705 pub secret_name: String,
707 #[serde(default, skip_serializing_if = "Option::is_none")]
709 pub namespace: Option<String>,
710}
711
712#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
721#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
722#[serde(rename_all = "camelCase")]
723pub struct StackSettings {
724 #[serde(default)]
727 pub endpoint_access: EndpointAccess,
728
729 #[serde(default, skip_serializing_if = "Option::is_none")]
736 pub network: Option<NetworkSettings>,
737
738 #[serde(default, skip_serializing_if = "Option::is_none")]
740 pub domains: Option<DomainSettings>,
741
742 #[serde(default, skip_serializing_if = "Option::is_none")]
744 pub kubernetes: Option<KubernetesSettings>,
745
746 #[serde(default, skip_serializing_if = "Option::is_none")]
752 pub compute: Option<ComputeSettings>,
753
754 #[serde(default, skip_serializing_if = "Option::is_none")]
760 #[cfg_attr(
761 feature = "openapi",
762 schema(value_type = Option<HashMap<String, HashMap<String, String>>>)
763 )]
764 pub public_endpoints: Option<crate::PublicEndpointUrls>,
765
766 #[serde(default, skip_serializing_if = "is_default_deployment_model")]
775 pub deployment_model: DeploymentModel,
776
777 #[serde(default, skip_serializing_if = "is_default_updates_mode")]
781 pub updates: UpdatesMode,
782
783 #[serde(default, skip_serializing_if = "is_default_telemetry_mode")]
788 pub telemetry: TelemetryMode,
789
790 #[serde(default, skip_serializing_if = "is_default_heartbeats_mode")]
794 pub heartbeats: HeartbeatsMode,
795
796 #[serde(default, skip_serializing_if = "Option::is_none")]
801 pub external_bindings: Option<crate::ExternalBindings>,
802}
803
804fn is_default_deployment_model(model: &DeploymentModel) -> bool {
805 *model == DeploymentModel::default()
806}
807
808fn is_default_updates_mode(mode: &UpdatesMode) -> bool {
809 *mode == UpdatesMode::default()
810}
811
812fn is_default_telemetry_mode(mode: &TelemetryMode) -> bool {
813 *mode == TelemetryMode::default()
814}
815
816fn is_default_heartbeats_mode(mode: &HeartbeatsMode) -> bool {
817 *mode == HeartbeatsMode::default()
818}
819
820#[cfg(test)]
821mod failure_domain_tests {
822 use super::*;
823
824 #[test]
825 fn old_compute_selection_deserializes_without_topology() {
826 let selection: ComputePoolSelection = serde_json::from_value(serde_json::json!({
827 "mode": "fixed",
828 "machines": 2,
829 "machine": "m7i.xlarge"
830 }))
831 .expect("existing selection should deserialize");
832 assert!(selection.failure_domains().is_none());
833 }
834
835 #[test]
836 fn rejects_duplicate_concrete_failure_domains() {
837 let selection = ComputePoolSelection::Fixed {
838 machines: 2,
839 machine: Some("m7i.xlarge".to_string()),
840 failure_domains: Some(FailureDomainSelection {
841 spread: 2,
842 selected_failure_domains: vec!["us-west-2a".to_string(); 2],
843 }),
844 };
845 assert_eq!(
846 selection.validate(),
847 Err("selected failure domains must be unique".to_string())
848 );
849 }
850
851 #[test]
852 fn fixed_pool_must_have_one_machine_per_failure_domain() {
853 let invalid = ComputePoolSelection::Fixed {
854 machines: 1,
855 machine: None,
856 failure_domains: Some(FailureDomainSelection {
857 spread: 2,
858 selected_failure_domains: Vec::new(),
859 }),
860 };
861 assert_eq!(
862 invalid.validate(),
863 Err(
864 "fixed compute pool machines (1) must be at least failure-domain spread (2)"
865 .to_string()
866 )
867 );
868
869 let valid = ComputePoolSelection::Fixed {
870 machines: 2,
871 machine: None,
872 failure_domains: Some(FailureDomainSelection {
873 spread: 2,
874 selected_failure_domains: Vec::new(),
875 }),
876 };
877 assert_eq!(valid.validate(), Ok(()));
878 }
879
880 #[test]
881 fn autoscaling_pool_bounds_must_cover_every_failure_domain() {
882 let invalid_max = ComputePoolSelection::Autoscale {
883 min: 1,
884 max: 1,
885 machine: None,
886 failure_domains: Some(FailureDomainSelection {
887 spread: 2,
888 selected_failure_domains: Vec::new(),
889 }),
890 };
891 assert_eq!(
892 invalid_max.validate(),
893 Err(
894 "autoscaling compute pool maximum (1) must be at least failure-domain spread (2)"
895 .to_string()
896 )
897 );
898
899 let invalid_min = ComputePoolSelection::Autoscale {
900 min: 1,
901 max: 3,
902 machine: None,
903 failure_domains: Some(FailureDomainSelection {
904 spread: 2,
905 selected_failure_domains: Vec::new(),
906 }),
907 };
908 assert_eq!(
909 invalid_min.validate(),
910 Err(
911 "autoscaling compute pool minimum (1) must be at least failure-domain spread (2)"
912 .to_string()
913 )
914 );
915
916 let valid = ComputePoolSelection::Autoscale {
917 min: 2,
918 max: 2,
919 machine: None,
920 failure_domains: Some(FailureDomainSelection {
921 spread: 2,
922 selected_failure_domains: Vec::new(),
923 }),
924 };
925 assert_eq!(valid.validate(), Ok(()));
926 }
927
928 #[test]
929 fn machine_public_endpoints_round_trip_without_rewriting_urls() {
930 let settings: StackSettings = serde_json::from_value(serde_json::json!({
931 "publicEndpoints": {
932 "loader": {
933 "api": "https://loader.compute.example.com",
934 "shares": "https://shares.compute.example.com",
935 "webhooks": "https://webhooks.compute.example.com"
936 }
937 }
938 }))
939 .expect("stack settings should deserialize");
940
941 assert_eq!(
942 serde_json::to_value(settings).expect("stack settings should serialize")
943 ["publicEndpoints"]["loader"]["shares"],
944 "https://shares.compute.example.com"
945 );
946 }
947}