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
179pub const MAX_CREATED_NETWORK_AVAILABILITY_ZONES: u8 = 8;
183
184#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
190#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
191#[serde(rename_all = "camelCase")]
192pub struct ComputeSettings {
193 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
195 pub pools: HashMap<String, ComputePoolSelection>,
196 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
198 pub containers: HashMap<String, ContainerResourceSelection>,
199}
200
201#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
203#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
204#[serde(rename_all = "camelCase", deny_unknown_fields)]
205pub struct ContainerResourceSelection {
206 #[serde(
208 default,
209 skip_serializing_if = "Option::is_none",
210 deserialize_with = "deserialize_selected_cpu"
211 )]
212 #[cfg_attr(feature = "openapi", schema(value_type = Option<f64>))]
213 pub cpu: Option<serde_json::Number>,
214 #[serde(default, skip_serializing_if = "Option::is_none")]
216 pub memory: Option<String>,
217 #[serde(default, skip_serializing_if = "Option::is_none")]
220 #[serde(deserialize_with = "deserialize_selected_volume_size")]
221 pub volume_size_gib: Option<u32>,
222}
223
224fn deserialize_selected_volume_size<'de, D: serde::Deserializer<'de>>(
225 deserializer: D,
226) -> std::result::Result<Option<u32>, D::Error> {
227 match Option::<serde_json::Value>::deserialize(deserializer)? {
229 None => Ok(None),
230 Some(serde_json::Value::Number(number)) => number
231 .as_u64()
232 .and_then(|number| u32::try_from(number).ok())
233 .map(Some)
234 .ok_or_else(|| {
235 serde::de::Error::custom("volumeSizeGib must be a whole positive capacity")
236 }),
237 Some(serde_json::Value::String(number)) => {
238 number.parse().map(Some).map_err(serde::de::Error::custom)
239 }
240 Some(_) => Err(serde::de::Error::custom(
241 "volumeSizeGib must be a whole positive capacity",
242 )),
243 }
244}
245
246fn deserialize_selected_cpu<'de, D: serde::Deserializer<'de>>(
247 deserializer: D,
248) -> std::result::Result<Option<serde_json::Number>, D::Error> {
249 match Option::<serde_json::Value>::deserialize(deserializer)? {
251 None => Ok(None),
252 Some(serde_json::Value::Number(number)) => Ok(Some(number)),
253 Some(serde_json::Value::String(number)) => {
254 number.parse().map(Some).map_err(serde::de::Error::custom)
255 }
256 Some(_) => Err(serde::de::Error::custom("CPU must be a number")),
257 }
258}
259
260#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
262#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
263#[serde(rename_all = "camelCase")]
264pub struct FailureDomainSelection {
265 pub spread: u8,
267 #[serde(default, skip_serializing_if = "Vec::is_empty")]
270 pub selected_failure_domains: Vec<String>,
271}
272
273#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
275#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
276#[serde(rename_all = "camelCase", tag = "mode")]
277pub enum ComputePoolSelection {
278 Fixed {
280 machines: u32,
282 #[serde(
284 default,
285 skip_serializing_if = "Option::is_none",
286 deserialize_with = "deserialize_machine_override"
287 )]
288 machine: Option<String>,
289 #[serde(default, skip_serializing_if = "Option::is_none")]
291 failure_domains: Option<FailureDomainSelection>,
292 #[serde(
295 rename = "dataDiskGib",
296 default,
297 skip_serializing_if = "Option::is_none",
298 deserialize_with = "deserialize_selected_data_disk_gib"
299 )]
300 data_disk_gib: Option<u32>,
301 },
302 Autoscale {
304 min: u32,
306 max: u32,
308 #[serde(
310 default,
311 skip_serializing_if = "Option::is_none",
312 deserialize_with = "deserialize_machine_override"
313 )]
314 machine: Option<String>,
315 #[serde(default, skip_serializing_if = "Option::is_none")]
317 failure_domains: Option<FailureDomainSelection>,
318 #[serde(
321 rename = "dataDiskGib",
322 default,
323 skip_serializing_if = "Option::is_none",
324 deserialize_with = "deserialize_selected_data_disk_gib"
325 )]
326 data_disk_gib: Option<u32>,
327 },
328}
329
330fn deserialize_selected_data_disk_gib<'de, D: serde::Deserializer<'de>>(
331 deserializer: D,
332) -> std::result::Result<Option<u32>, D::Error> {
333 match Option::<serde_json::Value>::deserialize(deserializer)? {
335 None => Ok(None),
336 Some(serde_json::Value::Number(number)) => number
337 .as_u64()
338 .and_then(|number| u32::try_from(number).ok())
339 .map(Some)
340 .ok_or_else(|| serde::de::Error::custom("dataDiskGib must be a whole number of GiB")),
341 Some(serde_json::Value::String(number)) => number
342 .parse()
343 .map(Some)
344 .map_err(|_| serde::de::Error::custom("dataDiskGib must be a whole number of GiB")),
345 Some(_) => Err(serde::de::Error::custom(
346 "dataDiskGib must be a whole number of GiB",
347 )),
348 }
349}
350
351fn deserialize_machine_override<'de, D: serde::Deserializer<'de>>(
352 deserializer: D,
353) -> std::result::Result<Option<String>, D::Error> {
354 Ok(Option::<String>::deserialize(deserializer)?.filter(|machine| !machine.is_empty()))
356}
357
358impl ComputePoolSelection {
359 pub fn machine(&self) -> Option<&str> {
361 match self {
362 Self::Fixed { machine, .. } | Self::Autoscale { machine, .. } => machine.as_deref(),
363 }
364 }
365
366 pub fn failure_domains(&self) -> Option<&FailureDomainSelection> {
368 match self {
369 Self::Fixed {
370 failure_domains, ..
371 }
372 | Self::Autoscale {
373 failure_domains, ..
374 } => failure_domains.as_ref(),
375 }
376 }
377
378 pub fn data_disk_gib(&self) -> Option<u32> {
380 match self {
381 Self::Fixed { data_disk_gib, .. } | Self::Autoscale { data_disk_gib, .. } => {
382 *data_disk_gib
383 }
384 }
385 }
386
387 pub fn min_size(&self) -> u32 {
389 match self {
390 Self::Fixed { machines, .. } => *machines,
391 Self::Autoscale { min, .. } => *min,
392 }
393 }
394
395 pub fn max_size(&self) -> u32 {
397 match self {
398 Self::Fixed { machines, .. } => *machines,
399 Self::Autoscale { max, .. } => *max,
400 }
401 }
402
403 pub fn validate(&self) -> std::result::Result<(), String> {
405 if self
406 .failure_domains()
407 .is_some_and(|selection| selection.spread == 0)
408 {
409 return Err("failure-domain spread must be at least one".to_string());
410 }
411 if self.failure_domains().is_some_and(|selection| {
412 !selection.selected_failure_domains.is_empty()
413 && selection.selected_failure_domains.len() != usize::from(selection.spread)
414 }) {
415 return Err("selected failure domains must match the requested spread".to_string());
416 }
417 if self.failure_domains().is_some_and(|selection| {
418 selection.selected_failure_domains.len()
419 != selection
420 .selected_failure_domains
421 .iter()
422 .collect::<std::collections::HashSet<_>>()
423 .len()
424 }) {
425 return Err("selected failure domains must be unique".to_string());
426 }
427 match self {
428 Self::Fixed { machines, .. } => {
429 if *machines == 0 {
430 return Err("fixed compute pools must select at least one machine".to_string());
431 }
432 if let Some(failure_domains) = self.failure_domains() {
433 if *machines < u32::from(failure_domains.spread) {
434 return Err(format!(
435 "fixed compute pool machines ({machines}) must be at least failure-domain spread ({})",
436 failure_domains.spread
437 ));
438 }
439 }
440 }
441 Self::Autoscale { min, max, .. } => {
442 if min > max {
443 return Err(format!(
444 "autoscaling compute pool minimum ({min}) cannot exceed maximum ({max})"
445 ));
446 }
447 if let Some(failure_domains) = self.failure_domains() {
448 let spread = u32::from(failure_domains.spread);
449 if *max < spread {
450 return Err(format!(
451 "autoscaling compute pool maximum ({max}) must be at least failure-domain spread ({spread})"
452 ));
453 }
454 if *min < spread {
455 return Err(format!(
456 "autoscaling compute pool minimum ({min}) must be at least failure-domain spread ({spread})"
457 ));
458 }
459 }
460 }
461 }
462 Ok(())
463 }
464}
465
466#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
468#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
469#[serde(rename_all = "camelCase")]
470pub enum DeploymentModel {
471 #[default]
474 Push,
475 Pull,
478}
479
480#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
482#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
483#[serde(rename_all = "kebab-case")]
484pub enum UpdatesMode {
485 #[default]
487 Auto,
488 ApprovalRequired,
490}
491
492#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
494#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
495#[serde(rename_all = "kebab-case")]
496pub enum TelemetryMode {
497 Off,
499 #[default]
501 Auto,
502 ApprovalRequired,
504}
505
506impl TelemetryMode {
507 pub fn is_enabled(&self) -> bool {
509 !matches!(self, TelemetryMode::Off)
510 }
511}
512
513#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
515#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
516#[serde(rename_all = "kebab-case")]
517pub enum HeartbeatsMode {
518 Off,
520 #[default]
522 On,
523}
524
525impl HeartbeatsMode {
526 pub fn is_enabled(&self) -> bool {
528 matches!(self, HeartbeatsMode::On)
529 }
530}
531
532#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
534#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
535#[serde(rename_all = "camelCase")]
536pub enum EndpointAccess {
537 #[default]
539 Internet,
540 Private,
542}
543
544impl EndpointAccess {
545 pub fn as_str(self) -> &'static str {
547 match self {
548 Self::Internet => "internet",
549 Self::Private => "private",
550 }
551 }
552}
553
554#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
559#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
560#[serde(rename_all = "camelCase")]
561pub struct DomainSettings {
562 #[serde(default, skip_serializing_if = "Option::is_none")]
564 pub custom_domains: Option<HashMap<String, CustomDomainConfig>>,
565 #[serde(default, skip_serializing_if = "Option::is_none")]
571 pub public_endpoint_target: Option<PublicEndpointTargetSettings>,
572}
573
574#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
576#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
577#[serde(rename_all = "camelCase", tag = "mode")]
578pub enum PublicEndpointTargetSettings {
579 MachineAddresses,
581 #[serde(rename_all = "camelCase")]
583 LoadBalancer {
584 cname_target: String,
586 },
587}
588
589#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
591#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
592#[serde(rename_all = "camelCase")]
593pub struct CustomDomainConfig {
594 #[serde(default, skip_serializing_if = "String::is_empty")]
596 pub domain: String,
597 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
599 pub endpoints: HashMap<String, String>,
600 pub certificate: CustomCertificateConfig,
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 CustomCertificateConfig {
609 #[serde(default, skip_serializing_if = "Option::is_none")]
611 pub managed: Option<ManagedCustomCertificateConfig>,
612 #[serde(default, skip_serializing_if = "Option::is_none")]
614 pub aws: Option<AwsCustomCertificateConfig>,
615 #[serde(default, skip_serializing_if = "Option::is_none")]
617 pub gcp: Option<GcpCustomCertificateConfig>,
618 #[serde(default, skip_serializing_if = "Option::is_none")]
620 pub azure: Option<AzureCustomCertificateConfig>,
621 #[serde(default, skip_serializing_if = "Option::is_none")]
623 pub kubernetes: Option<KubernetesCustomCertificateConfig>,
624}
625
626#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
628#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
629#[serde(rename_all = "camelCase", deny_unknown_fields)]
630pub struct ManagedCustomCertificateConfig {}
631
632#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
633#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
634#[serde(rename_all = "camelCase")]
635pub struct AwsCustomCertificateConfig {
636 pub certificate_arn: String,
637}
638
639#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
640#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
641#[serde(rename_all = "camelCase")]
642pub struct GcpCustomCertificateConfig {
643 pub certificate_name: String,
644}
645
646#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
647#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
648#[serde(rename_all = "camelCase")]
649pub struct AzureCustomCertificateConfig {
650 pub key_vault_certificate_id: String,
651 #[serde(default, skip_serializing_if = "Option::is_none")]
652 pub key_vault_resource_id: Option<String>,
653}
654
655#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
656#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
657#[serde(rename_all = "camelCase")]
658pub struct KubernetesCustomCertificateConfig {
659 pub tls_secret_ref: KubernetesTlsSecretRef,
661}
662
663#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
670#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
671#[serde(rename_all = "camelCase")]
672pub struct KubernetesSettings {
673 #[serde(default, skip_serializing_if = "Option::is_none")]
675 pub cluster: Option<KubernetesClusterSettings>,
676 #[serde(default, skip_serializing_if = "Option::is_none")]
678 pub exposure: Option<KubernetesExposureSettings>,
679}
680
681#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
683#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
684#[serde(rename_all = "camelCase")]
685pub struct KubernetesClusterSettings {
686 pub ownership: KubernetesClusterOwnership,
689 #[serde(default, skip_serializing_if = "Option::is_none")]
691 pub namespace: Option<String>,
692 #[serde(default, skip_serializing_if = "Option::is_none")]
694 pub cloud: Option<KubernetesCloudReference>,
695}
696
697#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
699#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
700#[serde(rename_all = "camelCase", tag = "mode")]
701pub enum KubernetesExposureSettings {
702 Disabled,
704 Generated {
706 route: KubernetesRouteProfile,
708 certificate: KubernetesCertificateMode,
710 },
711 Custom {
713 domain: String,
715 route: KubernetesRouteProfile,
717 certificate: KubernetesCertificateMode,
719 },
720 #[serde(rename_all = "camelCase")]
722 CustomBaseDomain {
723 base_domain: String,
725 route: KubernetesRouteProfile,
727 certificate: KubernetesCertificateMode,
729 },
730}
731
732#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
734#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
735#[serde(rename_all = "camelCase", tag = "routeApi")]
736pub enum KubernetesRouteProfile {
737 Ingress(KubernetesIngressRouteProfile),
739 Gateway(KubernetesGatewayRouteProfile),
741}
742
743#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
745#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
746#[serde(rename_all = "camelCase")]
747pub struct KubernetesIngressRouteProfile {
748 #[serde(default, skip_serializing_if = "Option::is_none")]
750 pub controller: Option<String>,
751 pub ingress_class_name: String,
753 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
755 pub labels: HashMap<String, String>,
756 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
758 pub annotations: HashMap<String, String>,
759 #[serde(default, skip_serializing_if = "Option::is_none")]
761 pub provider: Option<KubernetesRouteProviderOptions>,
762}
763
764#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
766#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
767#[serde(rename_all = "camelCase")]
768pub struct KubernetesGatewayRouteProfile {
769 #[serde(default, skip_serializing_if = "Option::is_none")]
771 pub controller: Option<String>,
772 pub gateway_class_name: String,
774 pub listener_port: u16,
776 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
778 pub labels: HashMap<String, String>,
779 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
781 pub annotations: HashMap<String, String>,
782 #[serde(default, skip_serializing_if = "Option::is_none")]
784 pub provider: Option<KubernetesRouteProviderOptions>,
785}
786
787#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
789#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
790#[serde(rename_all = "camelCase", tag = "provider")]
791pub enum KubernetesRouteProviderOptions {
792 #[serde(rename_all = "camelCase")]
794 AwsAlb {
795 scheme: String,
797 target_type: String,
799 #[serde(default, skip_serializing_if = "Option::is_none")]
801 ip_address_type: Option<String>,
802 #[serde(default, skip_serializing_if = "Vec::is_empty")]
804 subnet_ids: Vec<String>,
805 },
806 #[serde(rename_all = "camelCase")]
808 GkeGateway {
809 #[serde(default, skip_serializing_if = "Option::is_none")]
811 static_address_name: Option<String>,
812 },
813 #[serde(rename_all = "camelCase")]
815 AzureApplicationGatewayForContainers {
816 #[serde(default, skip_serializing_if = "Option::is_none")]
818 alb_namespace: Option<String>,
819 #[serde(default, skip_serializing_if = "Option::is_none")]
821 alb_name: Option<String>,
822 frontend: String,
824 },
825}
826
827#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
829#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
830#[serde(rename_all = "camelCase", tag = "mode")]
831pub enum KubernetesCertificateMode {
832 #[serde(rename_all = "camelCase")]
834 ManagedAcmImport {
835 #[serde(default, skip_serializing_if = "Option::is_none")]
837 region: Option<String>,
838 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
840 tags: HashMap<String, String>,
841 },
842 #[serde(rename_all = "camelCase")]
844 AwsAcmArn {
845 certificate_arn: String,
847 },
848 #[serde(rename_all = "camelCase")]
850 ManagedTlsSecret {
851 secret_name_template: String,
853 },
854 TlsSecretRef(KubernetesTlsSecretRef),
856 None,
858}
859
860#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
862#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
863#[serde(rename_all = "camelCase")]
864pub struct KubernetesTlsSecretRef {
865 pub secret_name: String,
867 #[serde(default, skip_serializing_if = "Option::is_none")]
869 pub namespace: Option<String>,
870}
871
872#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
881#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
882#[serde(rename_all = "camelCase")]
883pub struct StackSettings {
884 #[serde(default, skip_serializing_if = "is_manual_setup_management")]
887 pub setup_management: SetupManagement,
888 #[serde(default, skip_serializing_if = "Vec::is_empty")]
892 pub quota_warning_overrides: Vec<String>,
893
894 #[serde(default)]
897 pub endpoint_access: EndpointAccess,
898
899 #[serde(default, skip_serializing_if = "Option::is_none")]
906 pub network: Option<NetworkSettings>,
907
908 #[serde(default, skip_serializing_if = "Option::is_none")]
910 pub domains: Option<DomainSettings>,
911
912 #[serde(default, skip_serializing_if = "Option::is_none")]
914 pub kubernetes: Option<KubernetesSettings>,
915
916 #[serde(default, skip_serializing_if = "Option::is_none")]
922 pub compute: Option<ComputeSettings>,
923
924 #[serde(default, skip_serializing_if = "Option::is_none")]
930 #[cfg_attr(
931 feature = "openapi",
932 schema(value_type = Option<HashMap<String, HashMap<String, String>>>)
933 )]
934 pub public_endpoints: Option<crate::PublicEndpointUrls>,
935
936 #[serde(default, skip_serializing_if = "is_default_deployment_model")]
945 pub deployment_model: DeploymentModel,
946
947 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
950 pub airgapped: bool,
951
952 #[serde(default, skip_serializing_if = "is_default_updates_mode")]
956 pub updates: UpdatesMode,
957
958 #[serde(default, skip_serializing_if = "is_default_telemetry_mode")]
963 pub telemetry: TelemetryMode,
964
965 #[serde(default, skip_serializing_if = "is_default_heartbeats_mode")]
969 pub heartbeats: HeartbeatsMode,
970
971 #[serde(default, skip_serializing_if = "Option::is_none")]
976 pub external_bindings: Option<crate::ExternalBindings>,
977}
978
979#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
981#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
982#[serde(rename_all = "camelCase")]
983pub enum SetupManagement {
984 #[default]
986 Manual,
987 Automatic,
989}
990
991impl SetupManagement {
992 pub fn as_str(self) -> &'static str {
994 match self {
995 Self::Manual => "manual",
996 Self::Automatic => "automatic",
997 }
998 }
999}
1000
1001fn is_manual_setup_management(mode: &SetupManagement) -> bool {
1002 *mode == SetupManagement::Manual
1003}
1004
1005fn is_default_deployment_model(model: &DeploymentModel) -> bool {
1006 *model == DeploymentModel::default()
1007}
1008
1009fn is_default_updates_mode(mode: &UpdatesMode) -> bool {
1010 *mode == UpdatesMode::default()
1011}
1012
1013fn is_default_telemetry_mode(mode: &TelemetryMode) -> bool {
1014 *mode == TelemetryMode::default()
1015}
1016
1017fn is_default_heartbeats_mode(mode: &HeartbeatsMode) -> bool {
1018 *mode == HeartbeatsMode::default()
1019}
1020
1021#[cfg(test)]
1022mod failure_domain_tests {
1023 use super::*;
1024
1025 #[test]
1026 fn old_compute_selection_deserializes_without_topology() {
1027 let selection: ComputePoolSelection = serde_json::from_value(serde_json::json!({
1028 "mode": "fixed",
1029 "machines": 2,
1030 "machine": "m7i.xlarge"
1031 }))
1032 .expect("existing selection should deserialize");
1033 assert!(selection.failure_domains().is_none());
1034 }
1035
1036 #[test]
1037 fn rejects_duplicate_concrete_failure_domains() {
1038 let selection = ComputePoolSelection::Fixed {
1039 machines: 2,
1040 machine: Some("m7i.xlarge".to_string()),
1041 failure_domains: Some(FailureDomainSelection {
1042 spread: 2,
1043 selected_failure_domains: vec!["us-west-2a".to_string(); 2],
1044 }),
1045 data_disk_gib: None,
1046 };
1047 assert_eq!(
1048 selection.validate(),
1049 Err("selected failure domains must be unique".to_string())
1050 );
1051 }
1052
1053 #[test]
1054 fn fixed_pool_must_have_one_machine_per_failure_domain() {
1055 let invalid = ComputePoolSelection::Fixed {
1056 machines: 1,
1057 machine: None,
1058 failure_domains: Some(FailureDomainSelection {
1059 spread: 2,
1060 selected_failure_domains: Vec::new(),
1061 }),
1062 data_disk_gib: None,
1063 };
1064 assert_eq!(
1065 invalid.validate(),
1066 Err(
1067 "fixed compute pool machines (1) must be at least failure-domain spread (2)"
1068 .to_string()
1069 )
1070 );
1071
1072 let valid = ComputePoolSelection::Fixed {
1073 machines: 2,
1074 machine: None,
1075 failure_domains: Some(FailureDomainSelection {
1076 spread: 2,
1077 selected_failure_domains: Vec::new(),
1078 }),
1079 data_disk_gib: None,
1080 };
1081 assert_eq!(valid.validate(), Ok(()));
1082 }
1083
1084 #[test]
1085 fn autoscaling_pool_bounds_must_cover_every_failure_domain() {
1086 let invalid_max = ComputePoolSelection::Autoscale {
1087 min: 1,
1088 max: 1,
1089 machine: None,
1090 failure_domains: Some(FailureDomainSelection {
1091 spread: 2,
1092 selected_failure_domains: Vec::new(),
1093 }),
1094 data_disk_gib: None,
1095 };
1096 assert_eq!(
1097 invalid_max.validate(),
1098 Err(
1099 "autoscaling compute pool maximum (1) must be at least failure-domain spread (2)"
1100 .to_string()
1101 )
1102 );
1103
1104 let invalid_min = ComputePoolSelection::Autoscale {
1105 min: 1,
1106 max: 3,
1107 machine: None,
1108 failure_domains: Some(FailureDomainSelection {
1109 spread: 2,
1110 selected_failure_domains: Vec::new(),
1111 }),
1112 data_disk_gib: None,
1113 };
1114 assert_eq!(
1115 invalid_min.validate(),
1116 Err(
1117 "autoscaling compute pool minimum (1) must be at least failure-domain spread (2)"
1118 .to_string()
1119 )
1120 );
1121
1122 let valid = ComputePoolSelection::Autoscale {
1123 min: 2,
1124 max: 2,
1125 machine: None,
1126 failure_domains: Some(FailureDomainSelection {
1127 spread: 2,
1128 selected_failure_domains: Vec::new(),
1129 }),
1130 data_disk_gib: None,
1131 };
1132 assert_eq!(valid.validate(), Ok(()));
1133 }
1134
1135 #[test]
1136 fn machine_public_endpoints_round_trip_without_rewriting_urls() {
1137 let settings: StackSettings = serde_json::from_value(serde_json::json!({
1138 "publicEndpoints": {
1139 "loader": {
1140 "api": "https://loader.compute.example.com",
1141 "shares": "https://shares.compute.example.com",
1142 "webhooks": "https://webhooks.compute.example.com"
1143 }
1144 }
1145 }))
1146 .expect("stack settings should deserialize");
1147
1148 assert_eq!(
1149 serde_json::to_value(settings).expect("stack settings should serialize")
1150 ["publicEndpoints"]["loader"]["shares"],
1151 "https://shares.compute.example.com"
1152 );
1153 }
1154
1155 #[test]
1156 fn custom_domains_preserve_managed_endpoint_names_and_supplied_certificates() {
1157 for config in [
1158 serde_json::json!({
1159 "endpoints": {
1160 "api": "q7.m2x.example.com",
1161 "shares": "*.q7.s9t.example.com",
1162 "webhooks": "*.q7.w3h.example.com"
1163 },
1164 "certificate": {"managed": {}}
1165 }),
1166 serde_json::json!({
1167 "domain": "app.example.com",
1168 "certificate": {"aws": {"certificateArn": "arn:example"}}
1169 }),
1170 ] {
1171 let parsed: CustomDomainConfig = serde_json::from_value(config.clone())
1172 .expect("custom domain configuration should deserialize");
1173 assert_eq!(serde_json::to_value(parsed).unwrap(), config);
1174 }
1175 }
1176}