use std::time::Duration;
use kube::{
Client,
api::{Api, DynamicObject, Patch, PatchParams},
core::GroupVersionKind,
discovery::{ApiResource, Discovery},
};
use serde::{Deserialize, Serialize};
use crate::error::{KubeError, Result};
const CRD_FIELD_MANAGER: &str = "sherpack-crd";
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum ResourceCategory {
Crd = 0,
Namespace = 1,
NamespaceConfig = 2,
ClusterRbac = 10,
NamespacedRbac = 11,
Config = 20,
Storage = 21,
Network = 30,
Workload = 40,
Batch = 50,
Autoscaling = 60,
CustomResource = 70,
Other = 100,
}
impl ResourceCategory {
pub fn from_resource(kind: &str, api_version: &str) -> Self {
match kind {
"CustomResourceDefinition" => Self::Crd,
"Namespace" => Self::Namespace,
"ResourceQuota" | "LimitRange" | "PodSecurityPolicy" => Self::NamespaceConfig,
"ClusterRole" | "ClusterRoleBinding" => Self::ClusterRbac,
"Role" | "RoleBinding" | "ServiceAccount" => Self::NamespacedRbac,
"ConfigMap" | "Secret" => Self::Config,
"PersistentVolume" | "PersistentVolumeClaim" | "StorageClass" => Self::Storage,
"NetworkPolicy" | "Service" | "Endpoints" | "Ingress" | "IngressClass" => Self::Network,
"Deployment" | "StatefulSet" | "DaemonSet" | "ReplicaSet" | "Pod" => Self::Workload,
"Job" | "CronJob" => Self::Batch,
"HorizontalPodAutoscaler" | "VerticalPodAutoscaler" | "PodDisruptionBudget" => {
Self::Autoscaling
}
_ => {
if Self::is_custom_api_version(api_version) {
Self::CustomResource
} else {
Self::Other
}
}
}
}
pub fn is_custom_api_version(api_version: &str) -> bool {
let core_groups = [
"v1",
"apps",
"batch",
"autoscaling",
"policy",
"networking.k8s.io",
"rbac.authorization.k8s.io",
"storage.k8s.io",
"admissionregistration.k8s.io",
"apiextensions.k8s.io",
"certificates.k8s.io",
"coordination.k8s.io",
"discovery.k8s.io",
"events.k8s.io",
"flowcontrol.apiserver.k8s.io",
"node.k8s.io",
"scheduling.k8s.io",
];
let group = api_version
.rsplit('/')
.next_back()
.map(|_| api_version.rsplit('/').nth(1).unwrap_or(api_version));
match group {
Some(g) => !core_groups.contains(&g),
None => !core_groups.contains(&api_version), }
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[allow(dead_code)] struct CrdCondition {
#[serde(rename = "type")]
condition_type: String,
status: String,
#[serde(default)]
reason: Option<String>,
#[serde(default)]
message: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[allow(dead_code)] struct CrdStatus {
#[serde(default)]
conditions: Vec<CrdCondition>,
#[serde(default)]
accepted_names: Option<CrdAcceptedNames>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[allow(dead_code)] struct CrdAcceptedNames {
kind: String,
plural: String,
}
pub struct CrdManager {
client: Client,
}
impl CrdManager {
pub fn new(client: Client) -> Self {
Self { client }
}
pub fn client(&self) -> &Client {
&self.client
}
pub async fn apply_crd(&self, manifest: &str, dry_run: bool) -> Result<CrdApplyResult> {
let obj: DynamicObject = serde_yaml::from_str(manifest)
.map_err(|e| KubeError::Serialization(format!("Invalid CRD YAML: {}", e)))?;
let name = obj
.metadata
.name
.as_deref()
.ok_or_else(|| KubeError::InvalidConfig("CRD missing metadata.name".to_string()))?;
let api: Api<DynamicObject> = Api::all_with(
self.client.clone(),
&ApiResource::erase::<k8s_openapi::apiextensions_apiserver::pkg::apis::apiextensions::v1::CustomResourceDefinition>(&()),
);
let exists = api.get_opt(name).await.map_err(KubeError::Api)?.is_some();
let patch_params = PatchParams {
field_manager: Some(CRD_FIELD_MANAGER.to_string()),
dry_run,
force: true, ..Default::default()
};
api.patch(name, &patch_params, &Patch::Apply(&obj))
.await
.map_err(|e| {
KubeError::InvalidConfig(format!("Failed to apply CRD {}: {}", name, e))
})?;
Ok(CrdApplyResult {
name: name.to_string(),
created: !exists,
})
}
pub async fn get_crd(&self, name: &str) -> Result<Option<DynamicObject>> {
let api: Api<DynamicObject> = Api::all_with(
self.client.clone(),
&ApiResource::erase::<k8s_openapi::apiextensions_apiserver::pkg::apis::apiextensions::v1::CustomResourceDefinition>(&()),
);
api.get_opt(name).await.map_err(KubeError::Api)
}
pub async fn wait_for_crd(&self, name: &str, timeout: Duration) -> Result<()> {
use tokio::time::{Instant, sleep};
let api: Api<DynamicObject> = Api::all_with(
self.client.clone(),
&ApiResource::erase::<k8s_openapi::apiextensions_apiserver::pkg::apis::apiextensions::v1::CustomResourceDefinition>(&()),
);
let start = Instant::now();
let poll_interval = Duration::from_millis(500);
loop {
if start.elapsed() > timeout {
return Err(KubeError::Timeout(format!(
"CRD {} not established after {:?}",
name, timeout
)));
}
match api.get(name).await {
Ok(crd) => {
if Self::is_crd_established(&crd) {
return Ok(());
}
}
Err(kube::Error::Api(resp)) if resp.code == 404 => {
}
Err(e) => return Err(KubeError::Api(e)),
}
sleep(poll_interval).await;
}
}
fn is_crd_established(crd: &DynamicObject) -> bool {
let status = crd.data.get("status");
let conditions = status
.and_then(|s| s.get("conditions"))
.and_then(|c| c.as_array());
conditions
.map(|conds| {
conds.iter().any(|c| {
c.get("type").and_then(|t| t.as_str()) == Some("Established")
&& c.get("status").and_then(|s| s.as_str()) == Some("True")
})
})
.unwrap_or(false)
}
pub async fn apply_crds(
&self,
manifests: &[String],
timeout: Duration,
dry_run: bool,
) -> Result<Vec<CrdApplyResult>> {
let mut results = Vec::with_capacity(manifests.len());
let mut crd_names = Vec::with_capacity(manifests.len());
for manifest in manifests {
let result = self.apply_crd(manifest, dry_run).await?;
crd_names.push(result.name.clone());
results.push(result);
}
if !dry_run {
for name in &crd_names {
self.wait_for_crd(name, timeout).await?;
}
}
Ok(results)
}
pub async fn count_custom_resources(
&self,
discovery: &Discovery,
crd_name: &str,
) -> Result<usize> {
let parts: Vec<&str> = crd_name.splitn(2, '.').collect();
if parts.len() != 2 {
return Ok(0); }
let plural = parts[0];
let group = parts[1];
let gvk = GroupVersionKind {
group: group.to_string(),
version: "v1".to_string(), kind: plural.to_string(),
};
if let Some((ar, _caps)) = discovery.resolve_gvk(&gvk) {
let api: Api<DynamicObject> = Api::all_with(self.client.clone(), &ar);
match api.list(&Default::default()).await {
Ok(list) => Ok(list.items.len()),
Err(_) => Ok(0),
}
} else {
Ok(0)
}
}
pub async fn delete_crd(&self, name: &str) -> Result<()> {
use kube::api::DeleteParams;
let api: Api<DynamicObject> = Api::all_with(
self.client.clone(),
&ApiResource::erase::<k8s_openapi::apiextensions_apiserver::pkg::apis::apiextensions::v1::CustomResourceDefinition>(&()),
);
api.delete(name, &DeleteParams::default())
.await
.map_err(|e| {
KubeError::InvalidConfig(format!("Failed to delete CRD {}: {}", name, e))
})?;
Ok(())
}
}
#[derive(Debug, Clone)]
pub struct CrdApplyResult {
pub name: String,
pub created: bool,
}
impl CrdApplyResult {
pub fn message(&self) -> String {
if self.created {
format!("created CRD {}", self.name)
} else {
format!("updated CRD {}", self.name)
}
}
}
#[derive(Debug, Default)]
pub struct CrdUpgradeResult {
pub applied: Vec<CrdApplyResult>,
pub skipped: Vec<(String, String)>,
pub rejected: Vec<(String, String)>,
pub warnings: Vec<String>,
}
impl CrdUpgradeResult {
pub fn empty() -> Self {
Self::default()
}
pub fn is_success(&self) -> bool {
self.rejected.is_empty()
}
pub fn total(&self) -> usize {
self.applied.len() + self.skipped.len() + self.rejected.len()
}
pub fn add_applied(&mut self, result: CrdApplyResult) {
self.applied.push(result);
}
pub fn add_skipped(&mut self, name: String, reason: String) {
self.skipped.push((name, reason));
}
pub fn add_rejected(&mut self, name: String, reason: String) {
self.rejected.push((name, reason));
}
pub fn add_warning(&mut self, warning: String) {
self.warnings.push(warning);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_resource_category_crd() {
assert_eq!(
ResourceCategory::from_resource("CustomResourceDefinition", "apiextensions.k8s.io/v1"),
ResourceCategory::Crd
);
}
#[test]
fn test_resource_category_namespace() {
assert_eq!(
ResourceCategory::from_resource("Namespace", "v1"),
ResourceCategory::Namespace
);
}
#[test]
fn test_resource_category_rbac() {
assert_eq!(
ResourceCategory::from_resource("ClusterRole", "rbac.authorization.k8s.io/v1"),
ResourceCategory::ClusterRbac
);
assert_eq!(
ResourceCategory::from_resource("ServiceAccount", "v1"),
ResourceCategory::NamespacedRbac
);
}
#[test]
fn test_resource_category_workload() {
assert_eq!(
ResourceCategory::from_resource("Deployment", "apps/v1"),
ResourceCategory::Workload
);
assert_eq!(
ResourceCategory::from_resource("StatefulSet", "apps/v1"),
ResourceCategory::Workload
);
}
#[test]
fn test_resource_category_custom_resource() {
assert_eq!(
ResourceCategory::from_resource("Certificate", "cert-manager.io/v1"),
ResourceCategory::CustomResource
);
assert_eq!(
ResourceCategory::from_resource("VirtualService", "networking.istio.io/v1beta1"),
ResourceCategory::CustomResource
);
}
#[test]
fn test_resource_category_ordering() {
assert!(ResourceCategory::Crd < ResourceCategory::Namespace);
assert!(ResourceCategory::Namespace < ResourceCategory::ClusterRbac);
assert!(ResourceCategory::ClusterRbac < ResourceCategory::Config);
assert!(ResourceCategory::Config < ResourceCategory::Workload);
assert!(ResourceCategory::Workload < ResourceCategory::CustomResource);
assert!(ResourceCategory::CustomResource < ResourceCategory::Other);
}
#[test]
fn test_is_custom_api_version() {
assert!(!ResourceCategory::is_custom_api_version("v1"));
assert!(!ResourceCategory::is_custom_api_version("apps/v1"));
assert!(!ResourceCategory::is_custom_api_version("batch/v1"));
assert!(ResourceCategory::is_custom_api_version(
"cert-manager.io/v1"
));
assert!(ResourceCategory::is_custom_api_version(
"example.com/v1alpha1"
));
}
#[test]
fn test_crd_apply_result_message() {
let created = CrdApplyResult {
name: "tests.example.com".to_string(),
created: true,
};
assert!(created.message().contains("created"));
let updated = CrdApplyResult {
name: "tests.example.com".to_string(),
created: false,
};
assert!(updated.message().contains("updated"));
}
#[test]
fn test_crd_upgrade_result() {
let mut result = CrdUpgradeResult::default();
result.add_applied(CrdApplyResult {
name: "test1.example.com".to_string(),
created: true,
});
result.add_skipped("test2.example.com".to_string(), "skipped".to_string());
result.add_rejected("test3.example.com".to_string(), "dangerous".to_string());
assert_eq!(result.total(), 3);
assert!(!result.is_success());
}
}