use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use tatara_lisp_derive::TataraDomain as DeriveTataraDomain;
#[derive(DeriveTataraDomain, Clone, Debug, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "camelCase")]
#[tatara(keyword = "defrouting")]
pub struct RoutingSpec {
#[serde(default)]
pub hostnames: Vec<RoutingHostname>,
pub backend: RoutingBackend,
#[serde(default)]
pub stable_name_claim: bool,
#[serde(default)]
pub priority: i32,
}
#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct RoutingHostname {
pub app: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub instance: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cluster: Option<String>,
}
#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct RoutingBackend {
pub service: String,
pub port: u16,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tls_issuer: Option<String>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub ingress_annotations: BTreeMap<String, String>,
}
impl RoutingSpec {
pub fn has_hostnames(&self) -> bool {
!self.hostnames.is_empty()
}
pub fn emitted_fqdn_count(&self, claim_held: bool) -> usize {
self.hostnames.len() * if claim_held { 2 } else { 1 }
}
}
impl RoutingHostname {
pub fn is_named(&self) -> bool {
self.instance.as_deref().is_some_and(|s| !s.is_empty())
}
}
#[cfg(test)]
mod tests {
use super::*;
fn akeyless_routing() -> RoutingSpec {
RoutingSpec {
hostnames: vec![
RoutingHostname {
app: "gator".into(),
instance: Some("akeyless-prod".into()),
cluster: None,
},
RoutingHostname {
app: "gateway".into(),
instance: Some("akeyless-prod".into()),
cluster: None,
},
],
backend: RoutingBackend {
service: "akeyless-saas-akeyless-gateway".into(),
port: 8000,
tls_issuer: None,
ingress_annotations: BTreeMap::new(),
},
stable_name_claim: true,
priority: 100,
}
}
#[test]
fn empty_routing_resolves_no_hostnames() {
let r = RoutingSpec {
hostnames: vec![],
backend: RoutingBackend {
service: "x".into(),
port: 80,
tls_issuer: None,
ingress_annotations: BTreeMap::new(),
},
stable_name_claim: false,
priority: 0,
};
assert!(!r.has_hostnames());
assert_eq!(r.emitted_fqdn_count(false), 0);
assert_eq!(r.emitted_fqdn_count(true), 0);
}
#[test]
fn fqdn_count_doubles_when_claim_held() {
let r = akeyless_routing();
assert_eq!(r.emitted_fqdn_count(false), 2);
assert_eq!(r.emitted_fqdn_count(true), 4);
}
#[test]
fn hostname_is_named_when_instance_nonempty() {
let h = RoutingHostname {
app: "x".into(),
instance: Some("env-a".into()),
cluster: None,
};
assert!(h.is_named());
let h_anon = RoutingHostname {
app: "x".into(),
instance: None,
cluster: None,
};
assert!(!h_anon.is_named());
let h_empty = RoutingHostname {
app: "x".into(),
instance: Some(String::new()),
cluster: None,
};
assert!(!h_empty.is_named()); }
#[test]
fn serde_round_trip_via_yaml() {
let r = akeyless_routing();
let yaml = serde_yaml::to_string(&r).unwrap();
assert!(yaml.contains("hostnames:"));
assert!(yaml.contains("app: gator"));
assert!(yaml.contains("instance: akeyless-prod"));
assert!(yaml.contains("backend:"));
assert!(yaml.contains("service: akeyless-saas-akeyless-gateway"));
assert!(yaml.contains("port: 8000"));
assert!(yaml.contains("stableNameClaim: true"));
assert!(yaml.contains("priority: 100"));
let back: RoutingSpec = serde_yaml::from_str(&yaml).unwrap();
assert_eq!(back.hostnames.len(), 2);
assert!(back.stable_name_claim);
assert_eq!(back.priority, 100);
}
#[test]
fn empty_fields_skip_serialize() {
let r = RoutingSpec {
hostnames: vec![RoutingHostname {
app: "api".into(),
instance: None,
cluster: None,
}],
backend: RoutingBackend {
service: "svc".into(),
port: 8080,
tls_issuer: None,
ingress_annotations: BTreeMap::new(),
},
stable_name_claim: false,
priority: 0,
};
let yaml = serde_yaml::to_string(&r).unwrap();
assert!(!yaml.contains("instance:"));
assert!(!yaml.contains("cluster:"));
assert!(!yaml.contains("tlsIssuer:"));
assert!(!yaml.contains("ingressAnnotations:"));
}
#[test]
fn lisp_round_trip_via_defrouting() {
let src = r#"
(defrouting akeyless-edges
:hostnames ((:app "gator" :instance "akeyless-prod")
(:app "gateway" :instance "akeyless-prod"))
:backend (:service "akeyless-saas-akeyless-gateway"
:port 8000)
:stable-name-claim #t
:priority 100)
"#;
let defs: Vec<tatara_lisp::NamedDefinition<RoutingSpec>> =
tatara_lisp::compile_named::<RoutingSpec>(src).expect("compile");
assert_eq!(defs.len(), 1);
let d = &defs[0];
assert_eq!(d.name, "akeyless-edges");
assert_eq!(d.spec.hostnames.len(), 2);
assert_eq!(d.spec.hostnames[0].app, "gator");
assert_eq!(
d.spec.hostnames[0].instance.as_deref(),
Some("akeyless-prod")
);
assert_eq!(d.spec.backend.service, "akeyless-saas-akeyless-gateway");
assert_eq!(d.spec.backend.port, 8000);
assert!(d.spec.stable_name_claim);
assert_eq!(d.spec.priority, 100);
}
#[test]
fn lisp_round_trip_anonymous_instance() {
let src = r#"
(defrouting smoke-edges
:hostnames ((:app "smoke"))
:backend (:service "smoke" :port 80))
"#;
let defs: Vec<tatara_lisp::NamedDefinition<RoutingSpec>> =
tatara_lisp::compile_named::<RoutingSpec>(src).expect("compile");
let d = &defs[0];
assert_eq!(d.spec.hostnames.len(), 1);
assert_eq!(d.spec.hostnames[0].instance, None);
assert!(!d.spec.stable_name_claim); assert_eq!(d.spec.priority, 0); }
#[test]
fn ingress_annotations_round_trip() {
let mut annotations = BTreeMap::new();
annotations.insert(
"nginx.ingress.kubernetes.io/rate-limit".into(),
"100".into(),
);
annotations.insert(
"nginx.ingress.kubernetes.io/proxy-body-size".into(),
"10m".into(),
);
let r = RoutingSpec {
hostnames: vec![RoutingHostname {
app: "api".into(),
instance: None,
cluster: None,
}],
backend: RoutingBackend {
service: "svc".into(),
port: 8080,
tls_issuer: Some("letsencrypt-prod".into()),
ingress_annotations: annotations,
},
stable_name_claim: false,
priority: 0,
};
let yaml = serde_yaml::to_string(&r).unwrap();
assert!(yaml.contains("tlsIssuer: letsencrypt-prod"));
assert!(yaml.contains("rate-limit"));
let back: RoutingSpec = serde_yaml::from_str(&yaml).unwrap();
assert_eq!(
back.backend.tls_issuer.as_deref(),
Some("letsencrypt-prod")
);
assert_eq!(back.backend.ingress_annotations.len(), 2);
}
}