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, Serialize, Deserialize, PartialEq, Eq)]
400#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
401#[serde(rename_all = "camelCase")]
402pub struct DomainSettings {
403 #[serde(default, skip_serializing_if = "Option::is_none")]
405 pub custom_domains: Option<HashMap<String, CustomDomainConfig>>,
406 #[serde(default, skip_serializing_if = "Option::is_none")]
412 pub public_endpoint_target: Option<PublicEndpointTargetSettings>,
413}
414
415#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
417#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
418#[serde(rename_all = "camelCase", tag = "mode")]
419pub enum PublicEndpointTargetSettings {
420 MachineAddresses,
422 #[serde(rename_all = "camelCase")]
424 LoadBalancer {
425 cname_target: String,
427 },
428}
429
430#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
432#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
433#[serde(rename_all = "camelCase")]
434pub struct CustomDomainConfig {
435 pub domain: String,
437 pub certificate: CustomCertificateConfig,
439}
440
441#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
443#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
444#[serde(rename_all = "camelCase")]
445pub struct CustomCertificateConfig {
446 #[serde(default, skip_serializing_if = "Option::is_none")]
448 pub aws: Option<AwsCustomCertificateConfig>,
449 #[serde(default, skip_serializing_if = "Option::is_none")]
451 pub gcp: Option<GcpCustomCertificateConfig>,
452 #[serde(default, skip_serializing_if = "Option::is_none")]
454 pub azure: Option<AzureCustomCertificateConfig>,
455 #[serde(default, skip_serializing_if = "Option::is_none")]
457 pub kubernetes: Option<KubernetesCustomCertificateConfig>,
458}
459
460#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
461#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
462#[serde(rename_all = "camelCase")]
463pub struct AwsCustomCertificateConfig {
464 pub certificate_arn: String,
465}
466
467#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
468#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
469#[serde(rename_all = "camelCase")]
470pub struct GcpCustomCertificateConfig {
471 pub certificate_name: String,
472}
473
474#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
475#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
476#[serde(rename_all = "camelCase")]
477pub struct AzureCustomCertificateConfig {
478 pub key_vault_certificate_id: String,
479 #[serde(default, skip_serializing_if = "Option::is_none")]
480 pub key_vault_resource_id: Option<String>,
481}
482
483#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
484#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
485#[serde(rename_all = "camelCase")]
486pub struct KubernetesCustomCertificateConfig {
487 pub tls_secret_ref: KubernetesTlsSecretRef,
489}
490
491#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
498#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
499#[serde(rename_all = "camelCase")]
500pub struct KubernetesSettings {
501 #[serde(default, skip_serializing_if = "Option::is_none")]
503 pub cluster: Option<KubernetesClusterSettings>,
504 #[serde(default, skip_serializing_if = "Option::is_none")]
506 pub exposure: Option<KubernetesExposureSettings>,
507}
508
509#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
511#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
512#[serde(rename_all = "camelCase")]
513pub struct KubernetesClusterSettings {
514 pub ownership: KubernetesClusterOwnership,
517 #[serde(default, skip_serializing_if = "Option::is_none")]
519 pub namespace: Option<String>,
520 #[serde(default, skip_serializing_if = "Option::is_none")]
522 pub cloud: Option<KubernetesCloudReference>,
523}
524
525#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
527#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
528#[serde(rename_all = "camelCase", tag = "mode")]
529pub enum KubernetesExposureSettings {
530 Disabled,
532 Generated {
534 route: KubernetesRouteProfile,
536 certificate: KubernetesCertificateMode,
538 },
539 Custom {
541 domain: String,
543 route: KubernetesRouteProfile,
545 certificate: KubernetesCertificateMode,
547 },
548}
549
550#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
552#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
553#[serde(rename_all = "camelCase", tag = "routeApi")]
554pub enum KubernetesRouteProfile {
555 Ingress(KubernetesIngressRouteProfile),
557 Gateway(KubernetesGatewayRouteProfile),
559}
560
561#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
563#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
564#[serde(rename_all = "camelCase")]
565pub struct KubernetesIngressRouteProfile {
566 #[serde(default, skip_serializing_if = "Option::is_none")]
568 pub controller: Option<String>,
569 pub ingress_class_name: String,
571 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
573 pub labels: HashMap<String, String>,
574 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
576 pub annotations: HashMap<String, String>,
577 #[serde(default, skip_serializing_if = "Option::is_none")]
579 pub provider: Option<KubernetesRouteProviderOptions>,
580}
581
582#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
584#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
585#[serde(rename_all = "camelCase")]
586pub struct KubernetesGatewayRouteProfile {
587 #[serde(default, skip_serializing_if = "Option::is_none")]
589 pub controller: Option<String>,
590 pub gateway_class_name: String,
592 pub listener_port: u16,
594 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
596 pub labels: HashMap<String, String>,
597 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
599 pub annotations: HashMap<String, String>,
600 #[serde(default, skip_serializing_if = "Option::is_none")]
602 pub provider: Option<KubernetesRouteProviderOptions>,
603}
604
605#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
607#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
608#[serde(rename_all = "camelCase", tag = "provider")]
609pub enum KubernetesRouteProviderOptions {
610 #[serde(rename_all = "camelCase")]
612 AwsAlb {
613 scheme: String,
615 target_type: String,
617 #[serde(default, skip_serializing_if = "Option::is_none")]
619 ip_address_type: Option<String>,
620 #[serde(default, skip_serializing_if = "Vec::is_empty")]
622 subnet_ids: Vec<String>,
623 },
624 #[serde(rename_all = "camelCase")]
626 GkeGateway {
627 #[serde(default, skip_serializing_if = "Option::is_none")]
629 static_address_name: Option<String>,
630 },
631 #[serde(rename_all = "camelCase")]
633 AzureApplicationGatewayForContainers {
634 #[serde(default, skip_serializing_if = "Option::is_none")]
636 alb_namespace: Option<String>,
637 #[serde(default, skip_serializing_if = "Option::is_none")]
639 alb_name: Option<String>,
640 frontend: String,
642 },
643}
644
645#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
647#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
648#[serde(rename_all = "camelCase", tag = "mode")]
649pub enum KubernetesCertificateMode {
650 #[serde(rename_all = "camelCase")]
652 ManagedAcmImport {
653 #[serde(default, skip_serializing_if = "Option::is_none")]
655 region: Option<String>,
656 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
658 tags: HashMap<String, String>,
659 },
660 #[serde(rename_all = "camelCase")]
662 AwsAcmArn {
663 certificate_arn: String,
665 },
666 #[serde(rename_all = "camelCase")]
668 ManagedTlsSecret {
669 secret_name_template: String,
671 },
672 TlsSecretRef(KubernetesTlsSecretRef),
674 None,
676}
677
678#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
680#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
681#[serde(rename_all = "camelCase")]
682pub struct KubernetesTlsSecretRef {
683 pub secret_name: String,
685 #[serde(default, skip_serializing_if = "Option::is_none")]
687 pub namespace: Option<String>,
688}
689
690#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
699#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
700#[serde(rename_all = "camelCase")]
701pub struct StackSettings {
702 #[serde(default, skip_serializing_if = "Option::is_none")]
709 pub network: Option<NetworkSettings>,
710
711 #[serde(default, skip_serializing_if = "Option::is_none")]
713 pub domains: Option<DomainSettings>,
714
715 #[serde(default, skip_serializing_if = "Option::is_none")]
717 pub kubernetes: Option<KubernetesSettings>,
718
719 #[serde(default, skip_serializing_if = "Option::is_none")]
725 pub compute: Option<ComputeSettings>,
726
727 #[serde(default, skip_serializing_if = "Option::is_none")]
733 #[cfg_attr(
734 feature = "openapi",
735 schema(value_type = Option<HashMap<String, HashMap<String, String>>>)
736 )]
737 pub public_endpoints: Option<crate::PublicEndpointUrls>,
738
739 #[serde(default, skip_serializing_if = "is_default_deployment_model")]
748 pub deployment_model: DeploymentModel,
749
750 #[serde(default, skip_serializing_if = "is_default_updates_mode")]
754 pub updates: UpdatesMode,
755
756 #[serde(default, skip_serializing_if = "is_default_telemetry_mode")]
761 pub telemetry: TelemetryMode,
762
763 #[serde(default, skip_serializing_if = "is_default_heartbeats_mode")]
767 pub heartbeats: HeartbeatsMode,
768
769 #[serde(default, skip_serializing_if = "Option::is_none")]
774 #[cfg_attr(feature = "openapi", schema(value_type = Option<Object>))]
775 pub external_bindings: Option<crate::ExternalBindings>,
776}
777
778fn is_default_deployment_model(model: &DeploymentModel) -> bool {
779 *model == DeploymentModel::default()
780}
781
782fn is_default_updates_mode(mode: &UpdatesMode) -> bool {
783 *mode == UpdatesMode::default()
784}
785
786fn is_default_telemetry_mode(mode: &TelemetryMode) -> bool {
787 *mode == TelemetryMode::default()
788}
789
790fn is_default_heartbeats_mode(mode: &HeartbeatsMode) -> bool {
791 *mode == HeartbeatsMode::default()
792}
793
794#[cfg(test)]
795mod failure_domain_tests {
796 use super::*;
797
798 #[test]
799 fn old_compute_selection_deserializes_without_topology() {
800 let selection: ComputePoolSelection = serde_json::from_value(serde_json::json!({
801 "mode": "fixed",
802 "machines": 2,
803 "machine": "m7i.xlarge"
804 }))
805 .expect("existing selection should deserialize");
806 assert!(selection.failure_domains().is_none());
807 }
808
809 #[test]
810 fn rejects_duplicate_concrete_failure_domains() {
811 let selection = ComputePoolSelection::Fixed {
812 machines: 2,
813 machine: Some("m7i.xlarge".to_string()),
814 failure_domains: Some(FailureDomainSelection {
815 spread: 2,
816 selected_failure_domains: vec!["us-west-2a".to_string(); 2],
817 }),
818 };
819 assert_eq!(
820 selection.validate(),
821 Err("selected failure domains must be unique".to_string())
822 );
823 }
824
825 #[test]
826 fn fixed_pool_must_have_one_machine_per_failure_domain() {
827 let invalid = ComputePoolSelection::Fixed {
828 machines: 1,
829 machine: None,
830 failure_domains: Some(FailureDomainSelection {
831 spread: 2,
832 selected_failure_domains: Vec::new(),
833 }),
834 };
835 assert_eq!(
836 invalid.validate(),
837 Err(
838 "fixed compute pool machines (1) must be at least failure-domain spread (2)"
839 .to_string()
840 )
841 );
842
843 let valid = ComputePoolSelection::Fixed {
844 machines: 2,
845 machine: None,
846 failure_domains: Some(FailureDomainSelection {
847 spread: 2,
848 selected_failure_domains: Vec::new(),
849 }),
850 };
851 assert_eq!(valid.validate(), Ok(()));
852 }
853
854 #[test]
855 fn autoscaling_pool_bounds_must_cover_every_failure_domain() {
856 let invalid_max = ComputePoolSelection::Autoscale {
857 min: 1,
858 max: 1,
859 machine: None,
860 failure_domains: Some(FailureDomainSelection {
861 spread: 2,
862 selected_failure_domains: Vec::new(),
863 }),
864 };
865 assert_eq!(
866 invalid_max.validate(),
867 Err(
868 "autoscaling compute pool maximum (1) must be at least failure-domain spread (2)"
869 .to_string()
870 )
871 );
872
873 let invalid_min = ComputePoolSelection::Autoscale {
874 min: 1,
875 max: 3,
876 machine: None,
877 failure_domains: Some(FailureDomainSelection {
878 spread: 2,
879 selected_failure_domains: Vec::new(),
880 }),
881 };
882 assert_eq!(
883 invalid_min.validate(),
884 Err(
885 "autoscaling compute pool minimum (1) must be at least failure-domain spread (2)"
886 .to_string()
887 )
888 );
889
890 let valid = ComputePoolSelection::Autoscale {
891 min: 2,
892 max: 2,
893 machine: None,
894 failure_domains: Some(FailureDomainSelection {
895 spread: 2,
896 selected_failure_domains: Vec::new(),
897 }),
898 };
899 assert_eq!(valid.validate(), Ok(()));
900 }
901
902 #[test]
903 fn machine_public_endpoints_round_trip_without_rewriting_urls() {
904 let settings: StackSettings = serde_json::from_value(serde_json::json!({
905 "publicEndpoints": {
906 "loader": {
907 "api": "https://loader.compute.example.com",
908 "shares": "https://shares.compute.example.com",
909 "webhooks": "https://webhooks.compute.example.com"
910 }
911 }
912 }))
913 .expect("stack settings should deserialize");
914
915 assert_eq!(
916 serde_json::to_value(settings).expect("stack settings should serialize")
917 ["publicEndpoints"]["loader"]["shares"],
918 "https://shares.compute.example.com"
919 );
920 }
921}