use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
#[derive(Debug, Clone, PartialEq)]
pub struct CrdSchema {
pub name: String,
pub group: String,
pub scope: CrdScope,
pub names: CrdNames,
pub versions: Vec<CrdVersionSchema>,
}
impl CrdSchema {
pub fn storage_version(&self) -> Option<&CrdVersionSchema> {
self.versions.iter().find(|v| v.storage)
}
pub fn served_versions(&self) -> impl Iterator<Item = &CrdVersionSchema> {
self.versions.iter().filter(|v| v.served)
}
pub fn has_version(&self, name: &str) -> bool {
self.versions.iter().any(|v| v.name == name)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize, Deserialize)]
#[serde(rename_all = "PascalCase")]
pub enum CrdScope {
#[default]
Namespaced,
Cluster,
}
impl std::fmt::Display for CrdScope {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Namespaced => write!(f, "Namespaced"),
Self::Cluster => write!(f, "Cluster"),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct CrdNames {
pub kind: String,
pub plural: String,
pub singular: Option<String>,
pub short_names: Vec<String>,
pub list_kind: Option<String>,
pub categories: Vec<String>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct CrdVersionSchema {
pub name: String,
pub served: bool,
pub storage: bool,
pub deprecated: bool,
pub deprecation_warning: Option<String>,
pub schema: Option<OpenApiSchema>,
pub printer_columns: Vec<PrinterColumn>,
pub subresources: Subresources,
}
impl CrdVersionSchema {
pub fn has_schema(&self) -> bool {
self.schema.is_some()
}
pub fn spec_schema(&self) -> Option<&SchemaProperty> {
self.schema.as_ref().and_then(|s| s.properties.get("spec"))
}
pub fn status_schema(&self) -> Option<&SchemaProperty> {
self.schema
.as_ref()
.and_then(|s| s.properties.get("status"))
}
}
#[derive(Debug, Clone, PartialEq, Default)]
pub struct OpenApiSchema {
pub properties: BTreeMap<String, SchemaProperty>,
pub required: Vec<String>,
pub x_preserve_unknown: bool,
}
impl OpenApiSchema {
pub fn has_property(&self, name: &str) -> bool {
self.properties.contains_key(name)
}
pub fn is_required(&self, name: &str) -> bool {
self.required.contains(&name.to_string())
}
}
#[derive(Debug, Clone, PartialEq, Default)]
pub struct SchemaProperty {
pub type_: PropertyType,
pub description: Option<String>,
pub default: Option<serde_json::Value>,
pub format: Option<String>,
pub pattern: Option<String>,
pub enum_values: Option<Vec<serde_json::Value>>,
pub minimum: Option<f64>,
pub maximum: Option<f64>,
pub exclusive_minimum: Option<f64>,
pub exclusive_maximum: Option<f64>,
pub multiple_of: Option<f64>,
pub min_length: Option<u64>,
pub max_length: Option<u64>,
pub min_items: Option<u64>,
pub max_items: Option<u64>,
pub unique_items: bool,
pub min_properties: Option<u64>,
pub max_properties: Option<u64>,
pub nullable: bool,
pub properties: Option<BTreeMap<String, SchemaProperty>>,
pub required: Option<Vec<String>>,
pub items: Option<Box<SchemaProperty>>,
pub additional_properties: Option<AdditionalProperties>,
pub x_preserve_unknown: bool,
pub x_embedded_resource: bool,
pub x_int_or_string: bool,
}
impl SchemaProperty {
pub fn string() -> Self {
Self {
type_: PropertyType::String,
..Default::default()
}
}
pub fn integer() -> Self {
Self {
type_: PropertyType::Integer,
..Default::default()
}
}
pub fn boolean() -> Self {
Self {
type_: PropertyType::Boolean,
..Default::default()
}
}
pub fn object(properties: BTreeMap<String, SchemaProperty>) -> Self {
Self {
type_: PropertyType::Object,
properties: Some(properties),
..Default::default()
}
}
pub fn array(items: SchemaProperty) -> Self {
Self {
type_: PropertyType::Array,
items: Some(Box::new(items)),
..Default::default()
}
}
pub fn has_nested_properties(&self) -> bool {
self.properties.as_ref().is_some_and(|p| !p.is_empty())
}
pub fn get_nested(&self, path: &str) -> Option<&SchemaProperty> {
let mut current = self;
for part in path.split('.') {
current = current.properties.as_ref()?.get(part)?;
}
Some(current)
}
pub fn is_required(&self, name: &str) -> bool {
self.required
.as_ref()
.is_some_and(|r| r.contains(&name.to_string()))
}
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub enum PropertyType {
String,
Integer,
Number,
Boolean,
Array,
#[default]
Object,
Unknown(String),
}
impl PropertyType {
pub fn parse(s: &str) -> Self {
match s.to_lowercase().as_str() {
"string" => Self::String,
"integer" => Self::Integer,
"number" => Self::Number,
"boolean" => Self::Boolean,
"array" => Self::Array,
"object" => Self::Object,
other => Self::Unknown(other.to_string()),
}
}
pub fn is_compatible_with(&self, other: &Self) -> bool {
match (self, other) {
(a, b) if a == b => true,
(Self::Integer, Self::Number) => true,
(Self::Unknown(_), _) | (_, Self::Unknown(_)) => false,
_ => false,
}
}
}
impl std::fmt::Display for PropertyType {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::String => write!(f, "string"),
Self::Integer => write!(f, "integer"),
Self::Number => write!(f, "number"),
Self::Boolean => write!(f, "boolean"),
Self::Array => write!(f, "array"),
Self::Object => write!(f, "object"),
Self::Unknown(s) => write!(f, "{}", s),
}
}
}
#[derive(Debug, Clone, PartialEq, Default)]
pub enum AdditionalProperties {
#[default]
Allowed,
Denied,
Schema(Box<SchemaProperty>),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PrinterColumn {
pub name: String,
pub type_: String,
pub json_path: String,
pub description: Option<String>,
pub priority: i32,
pub format: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct Subresources {
pub status: bool,
pub scale: Option<ScaleSubresource>,
}
impl Subresources {
pub fn any_enabled(&self) -> bool {
self.status || self.scale.is_some()
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ScaleSubresource {
pub spec_replicas_path: String,
pub status_replicas_path: String,
pub label_selector_path: Option<String>,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_property_type_compatibility() {
assert!(PropertyType::String.is_compatible_with(&PropertyType::String));
assert!(PropertyType::Integer.is_compatible_with(&PropertyType::Number));
assert!(!PropertyType::String.is_compatible_with(&PropertyType::Integer));
assert!(!PropertyType::Number.is_compatible_with(&PropertyType::Integer));
}
#[test]
fn test_schema_property_nested() {
let mut nested = BTreeMap::new();
nested.insert("replicas".to_string(), SchemaProperty::integer());
nested.insert("image".to_string(), SchemaProperty::string());
let spec = SchemaProperty {
type_: PropertyType::Object,
properties: Some(nested),
required: Some(vec!["replicas".to_string()]),
..Default::default()
};
assert!(spec.has_nested_properties());
assert!(spec.is_required("replicas"));
assert!(!spec.is_required("image"));
assert!(spec.get_nested("replicas").is_some());
assert!(spec.get_nested("nonexistent").is_none());
}
#[test]
fn test_crd_scope_display() {
assert_eq!(CrdScope::Namespaced.to_string(), "Namespaced");
assert_eq!(CrdScope::Cluster.to_string(), "Cluster");
}
#[test]
fn test_crd_schema_versions() {
let schema = CrdSchema {
name: "tests.example.com".to_string(),
group: "example.com".to_string(),
scope: CrdScope::Namespaced,
names: CrdNames {
kind: "Test".to_string(),
plural: "tests".to_string(),
..Default::default()
},
versions: vec![
CrdVersionSchema {
name: "v1".to_string(),
served: true,
storage: true,
deprecated: false,
deprecation_warning: None,
schema: None,
printer_columns: vec![],
subresources: Subresources::default(),
},
CrdVersionSchema {
name: "v1beta1".to_string(),
served: true,
storage: false,
deprecated: true,
deprecation_warning: Some("Use v1 instead".to_string()),
schema: None,
printer_columns: vec![],
subresources: Subresources::default(),
},
],
};
assert!(schema.has_version("v1"));
assert!(schema.has_version("v1beta1"));
assert!(!schema.has_version("v2"));
assert_eq!(schema.storage_version().unwrap().name, "v1");
assert_eq!(schema.served_versions().count(), 2);
}
}