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 = "is_default_deployment_model")]
736 pub deployment_model: DeploymentModel,
737
738 #[serde(default, skip_serializing_if = "is_default_updates_mode")]
742 pub updates: UpdatesMode,
743
744 #[serde(default, skip_serializing_if = "is_default_telemetry_mode")]
749 pub telemetry: TelemetryMode,
750
751 #[serde(default, skip_serializing_if = "is_default_heartbeats_mode")]
755 pub heartbeats: HeartbeatsMode,
756
757 #[serde(default, skip_serializing_if = "Option::is_none")]
762 #[cfg_attr(feature = "openapi", schema(value_type = Option<Object>))]
763 pub external_bindings: Option<crate::ExternalBindings>,
764}
765
766fn is_default_deployment_model(model: &DeploymentModel) -> bool {
767 *model == DeploymentModel::default()
768}
769
770fn is_default_updates_mode(mode: &UpdatesMode) -> bool {
771 *mode == UpdatesMode::default()
772}
773
774fn is_default_telemetry_mode(mode: &TelemetryMode) -> bool {
775 *mode == TelemetryMode::default()
776}
777
778fn is_default_heartbeats_mode(mode: &HeartbeatsMode) -> bool {
779 *mode == HeartbeatsMode::default()
780}
781
782#[cfg(test)]
783mod failure_domain_tests {
784 use super::*;
785
786 #[test]
787 fn old_compute_selection_deserializes_without_topology() {
788 let selection: ComputePoolSelection = serde_json::from_value(serde_json::json!({
789 "mode": "fixed",
790 "machines": 2,
791 "machine": "m7i.xlarge"
792 }))
793 .expect("existing selection should deserialize");
794 assert!(selection.failure_domains().is_none());
795 }
796
797 #[test]
798 fn rejects_duplicate_concrete_failure_domains() {
799 let selection = ComputePoolSelection::Fixed {
800 machines: 2,
801 machine: Some("m7i.xlarge".to_string()),
802 failure_domains: Some(FailureDomainSelection {
803 spread: 2,
804 selected_failure_domains: vec!["us-west-2a".to_string(); 2],
805 }),
806 };
807 assert_eq!(
808 selection.validate(),
809 Err("selected failure domains must be unique".to_string())
810 );
811 }
812
813 #[test]
814 fn fixed_pool_must_have_one_machine_per_failure_domain() {
815 let invalid = ComputePoolSelection::Fixed {
816 machines: 1,
817 machine: None,
818 failure_domains: Some(FailureDomainSelection {
819 spread: 2,
820 selected_failure_domains: Vec::new(),
821 }),
822 };
823 assert_eq!(
824 invalid.validate(),
825 Err(
826 "fixed compute pool machines (1) must be at least failure-domain spread (2)"
827 .to_string()
828 )
829 );
830
831 let valid = ComputePoolSelection::Fixed {
832 machines: 2,
833 machine: None,
834 failure_domains: Some(FailureDomainSelection {
835 spread: 2,
836 selected_failure_domains: Vec::new(),
837 }),
838 };
839 assert_eq!(valid.validate(), Ok(()));
840 }
841
842 #[test]
843 fn autoscaling_pool_bounds_must_cover_every_failure_domain() {
844 let invalid_max = ComputePoolSelection::Autoscale {
845 min: 1,
846 max: 1,
847 machine: None,
848 failure_domains: Some(FailureDomainSelection {
849 spread: 2,
850 selected_failure_domains: Vec::new(),
851 }),
852 };
853 assert_eq!(
854 invalid_max.validate(),
855 Err(
856 "autoscaling compute pool maximum (1) must be at least failure-domain spread (2)"
857 .to_string()
858 )
859 );
860
861 let invalid_min = ComputePoolSelection::Autoscale {
862 min: 1,
863 max: 3,
864 machine: None,
865 failure_domains: Some(FailureDomainSelection {
866 spread: 2,
867 selected_failure_domains: Vec::new(),
868 }),
869 };
870 assert_eq!(
871 invalid_min.validate(),
872 Err(
873 "autoscaling compute pool minimum (1) must be at least failure-domain spread (2)"
874 .to_string()
875 )
876 );
877
878 let valid = ComputePoolSelection::Autoscale {
879 min: 2,
880 max: 2,
881 machine: None,
882 failure_domains: Some(FailureDomainSelection {
883 spread: 2,
884 selected_failure_domains: Vec::new(),
885 }),
886 };
887 assert_eq!(valid.validate(), Ok(()));
888 }
889}