use kube::CustomResource; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; pub fn sanitize_bouncer_name(input: &str) -> String { input .to_lowercase() .chars() .map(|c| if c.is_alphanumeric() || c == '-' { c } else { '-' }) .collect::() .trim_matches('-') .to_string() } // Minimal SojuBouncer spec -- just enough for the portal to create CRs. // The full CRD is defined in the soju-operator crate. #[derive(CustomResource, Debug, Clone, Serialize, Deserialize, JsonSchema)] #[kube( group = "irc.now", version = "v1alpha1", kind = "SojuBouncer", namespaced, status = "SojuBouncerStatus" )] pub struct SojuBouncerSpec { pub hostname: String, #[serde(default, skip_serializing_if = "Option::is_none")] pub owner: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub hostname_cloak: Option, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub listeners: Vec, #[serde(default, skip_serializing_if = "Option::is_none")] pub auth: Option, } #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] pub struct Listener { pub address: String, #[serde(default)] pub tls: bool, #[serde(default)] pub websocket: bool, } #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] pub struct AuthSpec { pub oauth2_url: String, #[serde(default, skip_serializing_if = "Option::is_none")] pub secret_ref: Option, } pub fn default_bouncer_spec(name: &str, owner: &str, issuer_url: &str) -> SojuBouncerSpec { SojuBouncerSpec { hostname: format!("{name}.irc.now"), owner: Some(owner.to_string()), hostname_cloak: Some(format!("{name}.irc.now")), listeners: vec![ Listener { address: ":6667".to_string(), tls: false, websocket: false }, Listener { address: ":8080".to_string(), tls: false, websocket: true }, ], auth: Some(AuthSpec { oauth2_url: issuer_url.to_string(), secret_ref: Some("oidc-soju".to_string()), }), } } #[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)] pub struct SojuBouncerStatus { #[serde(default)] pub conditions: Vec, } #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] pub struct SojuBouncerCondition { #[serde(rename = "type")] pub type_: String, pub status: String, #[serde(default)] pub reason: Option, #[serde(default)] pub message: Option, } pub fn sanitize_name(input: &str) -> String { sanitize_bouncer_name(input) } #[derive(CustomResource, Debug, Clone, Serialize, Deserialize, JsonSchema)] #[kube( group = "irc.now", version = "v1alpha1", kind = "ErgoNetwork", namespaced, status = "ErgoNetworkStatus" )] pub struct ErgoNetworkSpec { pub hostname: String, #[serde(default, skip_serializing_if = "Option::is_none", rename = "networkName")] pub network_name: Option, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub listeners: Vec, #[serde(default, skip_serializing_if = "Option::is_none")] pub tls: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub route: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub oauth2: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub cloaking: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub storage: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub resources: Option, } #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] pub struct ErgoListener { pub address: String, #[serde(default)] pub tls: bool, } #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] pub struct ErgoTlsSpec { pub issuer_ref: ErgoIssuerRef, pub dns_names: Vec, } #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] pub struct ErgoIssuerRef { pub name: String, pub kind: String, } #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] pub struct ErgoRouteSpec { pub host: String, } #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] pub struct ErgoOAuth2Spec { pub introspection_url: String, pub secret_ref: String, #[serde(default)] pub autocreate: bool, #[serde(default, skip_serializing_if = "Option::is_none", rename = "clientId")] pub client_id: Option, } #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] pub struct ErgoCloakingSpec { pub netname: String, } #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] pub struct ErgoStorageSpec { pub size: String, #[serde(default, skip_serializing_if = "Option::is_none", rename = "storageClassName")] pub storage_class_name: Option, } #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] pub struct ErgoResourceRequirements { #[serde(default, skip_serializing_if = "Option::is_none")] pub requests: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub limits: Option, } #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] pub struct ErgoResourceList { #[serde(default, skip_serializing_if = "Option::is_none")] pub memory: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub cpu: Option, } #[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)] pub struct ErgoNetworkStatus { #[serde(default)] pub conditions: Vec, } #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] pub struct ErgoNetworkCondition { #[serde(rename = "type")] pub type_: String, pub status: String, #[serde(default)] pub reason: Option, #[serde(default)] pub message: Option, } pub fn default_network_spec(name: &str) -> ErgoNetworkSpec { let hostname = format!("{name}.irc.now"); ErgoNetworkSpec { hostname: hostname.clone(), network_name: None, listeners: vec![ ErgoListener { address: ":6697".to_string(), tls: true }, ErgoListener { address: ":6667".to_string(), tls: false }, ], tls: Some(ErgoTlsSpec { issuer_ref: ErgoIssuerRef { name: "letsencrypt-prod".to_string(), kind: "ClusterIssuer".to_string(), }, dns_names: vec![hostname.clone()], }), route: Some(ErgoRouteSpec { host: hostname.clone() }), oauth2: Some(ErgoOAuth2Spec { introspection_url: "https://auth.irc.now/realms/irc-now/protocol/openid-connect/token/introspect".to_string(), secret_ref: "oidc-ergo".to_string(), autocreate: true, client_id: None, }), cloaking: Some(ErgoCloakingSpec { netname: hostname, }), storage: Some(ErgoStorageSpec { size: "1Gi".to_string(), storage_class_name: Some("customer-workload-storage".to_string()), }), resources: Some(ErgoResourceRequirements { requests: Some(ErgoResourceList { memory: Some("64Mi".to_string()), cpu: Some("50m".to_string()), }), limits: Some(ErgoResourceList { memory: Some("256Mi".to_string()), cpu: Some("200m".to_string()), }), }), } } #[cfg(test)] mod tests { use super::*; #[test] fn bouncer_name_sanitizes_input() { assert_eq!(sanitize_bouncer_name("My Bouncer!"), "my-bouncer"); assert_eq!(sanitize_bouncer_name("test_123"), "test-123"); } #[test] fn bouncer_name_trims_dashes() { assert_eq!(sanitize_bouncer_name("--hello--"), "hello"); } #[test] fn bouncer_name_handles_empty() { assert_eq!(sanitize_bouncer_name(""), ""); } #[test] fn sanitize_name_delegates_to_bouncer() { assert_eq!(sanitize_name("My Network!"), "my-network"); assert_eq!(sanitize_name("--test--"), "test"); assert_eq!(sanitize_name(""), ""); } #[test] fn default_network_spec_sets_hostname() { let spec = default_network_spec("test"); assert_eq!(spec.hostname, "test.irc.now"); assert_eq!(spec.listeners.len(), 2); assert!(spec.tls.is_some()); assert!(spec.route.is_some()); assert!(spec.oauth2.is_some()); assert!(spec.cloaking.is_some()); assert!(spec.storage.is_some()); assert!(spec.resources.is_some()); } }