use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use crate::{KubernetesCloudReference, KubernetesClusterOwnership};
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[serde(rename_all = "camelCase")]
pub struct AwsManagementConfig {
pub managing_role_arn: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[serde(rename_all = "camelCase")]
pub struct GcpManagementConfig {
pub service_account_email: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[serde(rename_all = "camelCase")]
pub struct AzureManagementConfig {
pub managing_tenant_id: String,
pub oidc_issuer: String,
pub oidc_subject: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[serde(rename_all = "camelCase", tag = "platform")]
pub enum ManagementConfig {
Aws(AwsManagementConfig),
Gcp(GcpManagementConfig),
Azure(AzureManagementConfig),
Kubernetes,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[serde(rename_all = "camelCase", tag = "type")]
pub enum NetworkSettings {
#[serde(rename = "use-default")]
UseDefault,
#[serde(rename = "create")]
Create {
#[serde(skip_serializing_if = "Option::is_none")]
cidr: Option<String>,
#[serde(default = "default_availability_zones")]
availability_zones: u8,
},
#[serde(rename = "byo-vpc-aws")]
ByoVpcAws {
vpc_id: String,
public_subnet_ids: Vec<String>,
private_subnet_ids: Vec<String>,
#[serde(default)]
security_group_ids: Vec<String>,
},
#[serde(rename = "byo-vpc-gcp")]
ByoVpcGcp {
network_name: String,
subnet_name: String,
region: String,
},
#[serde(rename = "byo-vnet-azure")]
ByoVnetAzure {
vnet_resource_id: String,
public_subnet_name: String,
private_subnet_name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
application_gateway_subnet_name: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
private_endpoint_subnet_name: Option<String>,
},
}
fn default_availability_zones() -> u8 {
2
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[serde(rename_all = "camelCase")]
pub struct ComputeSettings {
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub pools: HashMap<String, ComputePoolSelection>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[serde(rename_all = "camelCase")]
pub struct FailureDomainSelection {
pub spread: u8,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub selected_failure_domains: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[serde(rename_all = "camelCase", tag = "mode")]
pub enum ComputePoolSelection {
Fixed {
machines: u32,
#[serde(default, skip_serializing_if = "Option::is_none")]
machine: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
failure_domains: Option<FailureDomainSelection>,
},
Autoscale {
min: u32,
max: u32,
#[serde(default, skip_serializing_if = "Option::is_none")]
machine: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
failure_domains: Option<FailureDomainSelection>,
},
}
impl ComputePoolSelection {
pub fn machine(&self) -> Option<&str> {
match self {
Self::Fixed { machine, .. } | Self::Autoscale { machine, .. } => machine.as_deref(),
}
}
pub fn failure_domains(&self) -> Option<&FailureDomainSelection> {
match self {
Self::Fixed {
failure_domains, ..
}
| Self::Autoscale {
failure_domains, ..
} => failure_domains.as_ref(),
}
}
pub fn min_size(&self) -> u32 {
match self {
Self::Fixed { machines, .. } => *machines,
Self::Autoscale { min, .. } => *min,
}
}
pub fn max_size(&self) -> u32 {
match self {
Self::Fixed { machines, .. } => *machines,
Self::Autoscale { max, .. } => *max,
}
}
pub fn validate(&self) -> std::result::Result<(), String> {
if self
.failure_domains()
.is_some_and(|selection| selection.spread == 0)
{
return Err("failure-domain spread must be at least one".to_string());
}
if self.failure_domains().is_some_and(|selection| {
!selection.selected_failure_domains.is_empty()
&& selection.selected_failure_domains.len() != usize::from(selection.spread)
}) {
return Err("selected failure domains must match the requested spread".to_string());
}
if self.failure_domains().is_some_and(|selection| {
selection.selected_failure_domains.len()
!= selection
.selected_failure_domains
.iter()
.collect::<std::collections::HashSet<_>>()
.len()
}) {
return Err("selected failure domains must be unique".to_string());
}
match self {
Self::Fixed { machines, .. } => {
if *machines == 0 {
return Err("fixed compute pools must select at least one machine".to_string());
}
if let Some(failure_domains) = self.failure_domains() {
if *machines < u32::from(failure_domains.spread) {
return Err(format!(
"fixed compute pool machines ({machines}) must be at least failure-domain spread ({})",
failure_domains.spread
));
}
}
}
Self::Autoscale { min, max, .. } => {
if min > max {
return Err(format!(
"autoscaling compute pool minimum ({min}) cannot exceed maximum ({max})"
));
}
if let Some(failure_domains) = self.failure_domains() {
let spread = u32::from(failure_domains.spread);
if *max < spread {
return Err(format!(
"autoscaling compute pool maximum ({max}) must be at least failure-domain spread ({spread})"
));
}
if *min < spread {
return Err(format!(
"autoscaling compute pool minimum ({min}) must be at least failure-domain spread ({spread})"
));
}
}
}
}
Ok(())
}
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[serde(rename_all = "camelCase")]
pub enum DeploymentModel {
#[default]
Push,
Pull,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[serde(rename_all = "kebab-case")]
pub enum UpdatesMode {
#[default]
Auto,
ApprovalRequired,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[serde(rename_all = "kebab-case")]
pub enum TelemetryMode {
Off,
#[default]
Auto,
ApprovalRequired,
}
impl TelemetryMode {
pub fn is_enabled(&self) -> bool {
!matches!(self, TelemetryMode::Off)
}
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[serde(rename_all = "kebab-case")]
pub enum HeartbeatsMode {
Off,
#[default]
On,
}
impl HeartbeatsMode {
pub fn is_enabled(&self) -> bool {
matches!(self, HeartbeatsMode::On)
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[serde(rename_all = "camelCase")]
pub struct DomainSettings {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub custom_domains: Option<HashMap<String, CustomDomainConfig>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub public_endpoint_target: Option<PublicEndpointTargetSettings>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[serde(rename_all = "camelCase", tag = "mode")]
pub enum PublicEndpointTargetSettings {
MachineAddresses,
#[serde(rename_all = "camelCase")]
LoadBalancer {
cname_target: String,
},
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[serde(rename_all = "camelCase")]
pub struct CustomDomainConfig {
pub domain: String,
pub certificate: CustomCertificateConfig,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[serde(rename_all = "camelCase")]
pub struct CustomCertificateConfig {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub aws: Option<AwsCustomCertificateConfig>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub gcp: Option<GcpCustomCertificateConfig>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub azure: Option<AzureCustomCertificateConfig>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub kubernetes: Option<KubernetesCustomCertificateConfig>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[serde(rename_all = "camelCase")]
pub struct AwsCustomCertificateConfig {
pub certificate_arn: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[serde(rename_all = "camelCase")]
pub struct GcpCustomCertificateConfig {
pub certificate_name: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[serde(rename_all = "camelCase")]
pub struct AzureCustomCertificateConfig {
pub key_vault_certificate_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub key_vault_resource_id: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[serde(rename_all = "camelCase")]
pub struct KubernetesCustomCertificateConfig {
pub tls_secret_ref: KubernetesTlsSecretRef,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[serde(rename_all = "camelCase")]
pub struct KubernetesSettings {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cluster: Option<KubernetesClusterSettings>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub exposure: Option<KubernetesExposureSettings>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[serde(rename_all = "camelCase")]
pub struct KubernetesClusterSettings {
pub ownership: KubernetesClusterOwnership,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub namespace: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cloud: Option<KubernetesCloudReference>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[serde(rename_all = "camelCase", tag = "mode")]
pub enum KubernetesExposureSettings {
Disabled,
Generated {
route: KubernetesRouteProfile,
certificate: KubernetesCertificateMode,
},
Custom {
domain: String,
route: KubernetesRouteProfile,
certificate: KubernetesCertificateMode,
},
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[serde(rename_all = "camelCase", tag = "routeApi")]
pub enum KubernetesRouteProfile {
Ingress(KubernetesIngressRouteProfile),
Gateway(KubernetesGatewayRouteProfile),
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[serde(rename_all = "camelCase")]
pub struct KubernetesIngressRouteProfile {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub controller: Option<String>,
pub ingress_class_name: String,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub labels: HashMap<String, String>,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub annotations: HashMap<String, String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub provider: Option<KubernetesRouteProviderOptions>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[serde(rename_all = "camelCase")]
pub struct KubernetesGatewayRouteProfile {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub controller: Option<String>,
pub gateway_class_name: String,
pub listener_port: u16,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub labels: HashMap<String, String>,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub annotations: HashMap<String, String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub provider: Option<KubernetesRouteProviderOptions>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[serde(rename_all = "camelCase", tag = "provider")]
pub enum KubernetesRouteProviderOptions {
#[serde(rename_all = "camelCase")]
AwsAlb {
scheme: String,
target_type: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
ip_address_type: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
subnet_ids: Vec<String>,
},
#[serde(rename_all = "camelCase")]
GkeGateway {
#[serde(default, skip_serializing_if = "Option::is_none")]
static_address_name: Option<String>,
},
#[serde(rename_all = "camelCase")]
AzureApplicationGatewayForContainers {
#[serde(default, skip_serializing_if = "Option::is_none")]
alb_namespace: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
alb_name: Option<String>,
frontend: String,
},
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[serde(rename_all = "camelCase", tag = "mode")]
pub enum KubernetesCertificateMode {
#[serde(rename_all = "camelCase")]
ManagedAcmImport {
#[serde(default, skip_serializing_if = "Option::is_none")]
region: Option<String>,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
tags: HashMap<String, String>,
},
#[serde(rename_all = "camelCase")]
AwsAcmArn {
certificate_arn: String,
},
#[serde(rename_all = "camelCase")]
ManagedTlsSecret {
secret_name_template: String,
},
TlsSecretRef(KubernetesTlsSecretRef),
None,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[serde(rename_all = "camelCase")]
pub struct KubernetesTlsSecretRef {
pub secret_name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub namespace: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[serde(rename_all = "camelCase")]
pub struct StackSettings {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub network: Option<NetworkSettings>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub domains: Option<DomainSettings>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub kubernetes: Option<KubernetesSettings>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub compute: Option<ComputeSettings>,
#[serde(default, skip_serializing_if = "is_default_deployment_model")]
pub deployment_model: DeploymentModel,
#[serde(default, skip_serializing_if = "is_default_updates_mode")]
pub updates: UpdatesMode,
#[serde(default, skip_serializing_if = "is_default_telemetry_mode")]
pub telemetry: TelemetryMode,
#[serde(default, skip_serializing_if = "is_default_heartbeats_mode")]
pub heartbeats: HeartbeatsMode,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[cfg_attr(feature = "openapi", schema(value_type = Option<Object>))]
pub external_bindings: Option<crate::ExternalBindings>,
}
fn is_default_deployment_model(model: &DeploymentModel) -> bool {
*model == DeploymentModel::default()
}
fn is_default_updates_mode(mode: &UpdatesMode) -> bool {
*mode == UpdatesMode::default()
}
fn is_default_telemetry_mode(mode: &TelemetryMode) -> bool {
*mode == TelemetryMode::default()
}
fn is_default_heartbeats_mode(mode: &HeartbeatsMode) -> bool {
*mode == HeartbeatsMode::default()
}
#[cfg(test)]
mod failure_domain_tests {
use super::*;
#[test]
fn old_compute_selection_deserializes_without_topology() {
let selection: ComputePoolSelection = serde_json::from_value(serde_json::json!({
"mode": "fixed",
"machines": 2,
"machine": "m7i.xlarge"
}))
.expect("existing selection should deserialize");
assert!(selection.failure_domains().is_none());
}
#[test]
fn rejects_duplicate_concrete_failure_domains() {
let selection = ComputePoolSelection::Fixed {
machines: 2,
machine: Some("m7i.xlarge".to_string()),
failure_domains: Some(FailureDomainSelection {
spread: 2,
selected_failure_domains: vec!["us-west-2a".to_string(); 2],
}),
};
assert_eq!(
selection.validate(),
Err("selected failure domains must be unique".to_string())
);
}
#[test]
fn fixed_pool_must_have_one_machine_per_failure_domain() {
let invalid = ComputePoolSelection::Fixed {
machines: 1,
machine: None,
failure_domains: Some(FailureDomainSelection {
spread: 2,
selected_failure_domains: Vec::new(),
}),
};
assert_eq!(
invalid.validate(),
Err(
"fixed compute pool machines (1) must be at least failure-domain spread (2)"
.to_string()
)
);
let valid = ComputePoolSelection::Fixed {
machines: 2,
machine: None,
failure_domains: Some(FailureDomainSelection {
spread: 2,
selected_failure_domains: Vec::new(),
}),
};
assert_eq!(valid.validate(), Ok(()));
}
#[test]
fn autoscaling_pool_bounds_must_cover_every_failure_domain() {
let invalid_max = ComputePoolSelection::Autoscale {
min: 1,
max: 1,
machine: None,
failure_domains: Some(FailureDomainSelection {
spread: 2,
selected_failure_domains: Vec::new(),
}),
};
assert_eq!(
invalid_max.validate(),
Err(
"autoscaling compute pool maximum (1) must be at least failure-domain spread (2)"
.to_string()
)
);
let invalid_min = ComputePoolSelection::Autoscale {
min: 1,
max: 3,
machine: None,
failure_domains: Some(FailureDomainSelection {
spread: 2,
selected_failure_domains: Vec::new(),
}),
};
assert_eq!(
invalid_min.validate(),
Err(
"autoscaling compute pool minimum (1) must be at least failure-domain spread (2)"
.to_string()
)
);
let valid = ComputePoolSelection::Autoscale {
min: 2,
max: 2,
machine: None,
failure_domains: Some(FailureDomainSelection {
spread: 2,
selected_failure_domains: Vec::new(),
}),
};
assert_eq!(valid.validate(), Ok(()));
}
}