use kube::{
Client,
api::{Api, DynamicObject, ListParams},
core::GroupVersionKind,
discovery::{ApiCapabilities, ApiResource, Discovery, Scope},
};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use super::policy::{CrdPolicy, DetectedCrd};
use crate::error::{KubeError, Result};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CrdDeletionImpact {
pub crd_name: String,
pub policy: CrdPolicy,
pub total_resources: usize,
pub by_namespace: HashMap<String, usize>,
pub deletion_allowed: bool,
pub blocked_reason: Option<String>,
}
impl CrdDeletionImpact {
pub fn empty(crd_name: impl Into<String>, policy: CrdPolicy) -> Self {
Self {
crd_name: crd_name.into(),
policy,
total_resources: 0,
by_namespace: HashMap::new(),
deletion_allowed: policy.allows_delete(),
blocked_reason: if policy.allows_delete() {
None
} else {
Some(format!("Policy '{}' does not allow deletion", policy))
},
}
}
pub fn with_resources(
crd_name: impl Into<String>,
policy: CrdPolicy,
by_namespace: HashMap<String, usize>,
) -> Self {
let total: usize = by_namespace.values().sum();
Self {
crd_name: crd_name.into(),
policy,
total_resources: total,
by_namespace,
deletion_allowed: policy.allows_delete(),
blocked_reason: if policy.allows_delete() {
None
} else {
Some(format!("Policy '{}' does not allow deletion", policy))
},
}
}
pub fn has_data_loss(&self) -> bool {
self.total_resources > 0
}
pub fn is_safe(&self) -> bool {
self.deletion_allowed && !self.has_data_loss()
}
pub fn sorted_namespaces(&self) -> Vec<(&String, &usize)> {
let mut sorted: Vec<_> = self.by_namespace.iter().collect();
sorted.sort_by(|a, b| b.1.cmp(a.1));
sorted
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct DeletionImpactSummary {
pub crds: Vec<CrdDeletionImpact>,
pub total_resources: usize,
pub total_crds: usize,
pub blocked_crds: Vec<String>,
}
impl DeletionImpactSummary {
pub fn new() -> Self {
Self::default()
}
pub fn add(&mut self, impact: CrdDeletionImpact) {
if !impact.deletion_allowed {
self.blocked_crds.push(impact.crd_name.clone());
} else {
self.total_resources += impact.total_resources;
self.total_crds += 1;
}
self.crds.push(impact);
}
pub fn has_blocked(&self) -> bool {
!self.blocked_crds.is_empty()
}
pub fn has_data_loss(&self) -> bool {
self.total_resources > 0
}
pub fn is_safe(&self) -> bool {
!self.has_blocked() && !self.has_data_loss()
}
pub fn affected_namespaces(&self) -> Vec<String> {
let mut namespaces: Vec<_> = self
.crds
.iter()
.flat_map(|c| c.by_namespace.keys())
.cloned()
.collect();
namespaces.sort();
namespaces.dedup();
namespaces
}
}
pub struct CrdProtection {
client: Client,
}
impl CrdProtection {
pub fn new(client: Client) -> Self {
Self { client }
}
pub async fn analyze_deletion_impact(
&self,
crd_name: &str,
policy: CrdPolicy,
) -> Result<CrdDeletionImpact> {
if !policy.allows_delete() {
return Ok(CrdDeletionImpact::empty(crd_name, policy));
}
let Some((plural, group)) = parse_crd_name(crd_name) else {
return Ok(CrdDeletionImpact::empty(crd_name, policy));
};
let discovery = Discovery::new(self.client.clone())
.run()
.await
.map_err(KubeError::Api)?;
let by_namespace = self
.count_resources_by_namespace(&discovery, &group, &plural)
.await?;
Ok(CrdDeletionImpact::with_resources(
crd_name,
policy,
by_namespace,
))
}
pub async fn analyze_multi_deletion_impact(
&self,
crds: &[DetectedCrd],
) -> Result<DeletionImpactSummary> {
let mut summary = DeletionImpactSummary::new();
for crd in crds {
let impact = self.analyze_deletion_impact(&crd.name, crd.policy).await?;
summary.add(impact);
}
Ok(summary)
}
async fn count_resources_by_namespace(
&self,
discovery: &Discovery,
group: &str,
plural: &str,
) -> Result<HashMap<String, usize>> {
for version in &["v1", "v1beta1", "v1alpha1", "v2", "v2beta1"] {
let gvk = GroupVersionKind {
group: group.to_string(),
version: version.to_string(),
kind: plural.to_string(), };
if let Some((ar, caps)) = discovery.resolve_gvk(&gvk) {
return self.count_with_api_resource(&ar, &caps).await;
}
}
Ok(HashMap::new())
}
async fn count_with_api_resource(
&self,
ar: &ApiResource,
caps: &ApiCapabilities,
) -> Result<HashMap<String, usize>> {
let mut by_namespace = HashMap::new();
if caps.scope == Scope::Namespaced {
let api: Api<DynamicObject> = Api::all_with(self.client.clone(), ar);
match api.list(&ListParams::default()).await {
Ok(list) => {
for item in list.items {
let ns = item
.metadata
.namespace
.unwrap_or_else(|| "default".to_string());
*by_namespace.entry(ns).or_insert(0) += 1;
}
}
Err(kube::Error::Api(resp)) if resp.code == 404 => {
}
Err(e) => return Err(KubeError::Api(e)),
}
} else {
let api: Api<DynamicObject> = Api::all_with(self.client.clone(), ar);
match api.list(&ListParams::default()).await {
Ok(list) => {
if !list.items.is_empty() {
by_namespace.insert("".to_string(), list.items.len());
}
}
Err(kube::Error::Api(resp)) if resp.code == 404 => {}
Err(e) => return Err(KubeError::Api(e)),
}
}
Ok(by_namespace)
}
}
fn parse_crd_name(name: &str) -> Option<(String, String)> {
let parts: Vec<&str> = name.splitn(2, '.').collect();
if parts.len() == 2 {
Some((parts[0].to_string(), parts[1].to_string()))
} else {
None
}
}
#[derive(Debug, Clone)]
pub struct DeletionConfirmation {
pub required: bool,
pub required_flags: Vec<String>,
pub explanation: String,
}
impl DeletionConfirmation {
pub fn not_required() -> Self {
Self {
required: false,
required_flags: vec![],
explanation: "No confirmation required".to_string(),
}
}
pub fn required(explanation: impl Into<String>, flags: Vec<String>) -> Self {
Self {
required: true,
required_flags: flags,
explanation: explanation.into(),
}
}
pub fn from_impact(summary: &DeletionImpactSummary) -> Self {
if summary.has_blocked() {
return Self::required(
format!(
"{} CRD(s) blocked by policy: {}",
summary.blocked_crds.len(),
summary.blocked_crds.join(", ")
),
vec![], );
}
if summary.has_data_loss() {
let mut flags = vec!["--delete-crds".to_string()];
flags.push("--confirm-crd-deletion".to_string());
return Self::required(
format!(
"Deleting {} CRD(s) will permanently delete {} CustomResource(s)",
summary.total_crds, summary.total_resources
),
flags,
);
}
if summary.total_crds > 0 {
return Self::required(
format!(
"Deleting {} CRD(s) (no existing CustomResources)",
summary.total_crds
),
vec!["--delete-crds".to_string()],
);
}
Self::not_required()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_crd_name() {
let (plural, group) = parse_crd_name("certificates.cert-manager.io").unwrap();
assert_eq!(plural, "certificates");
assert_eq!(group, "cert-manager.io");
let (plural, group) = parse_crd_name("tests.example.com").unwrap();
assert_eq!(plural, "tests");
assert_eq!(group, "example.com");
assert!(parse_crd_name("invalid").is_none());
}
#[test]
fn test_crd_deletion_impact_empty() {
let impact = CrdDeletionImpact::empty("tests.example.com", CrdPolicy::Managed);
assert_eq!(impact.total_resources, 0);
assert!(impact.deletion_allowed);
assert!(!impact.has_data_loss());
assert!(impact.is_safe());
}
#[test]
fn test_crd_deletion_impact_with_resources() {
let mut by_ns = HashMap::new();
by_ns.insert("production".to_string(), 10);
by_ns.insert("staging".to_string(), 5);
let impact =
CrdDeletionImpact::with_resources("tests.example.com", CrdPolicy::Managed, by_ns);
assert_eq!(impact.total_resources, 15);
assert!(impact.deletion_allowed);
assert!(impact.has_data_loss());
assert!(!impact.is_safe());
let sorted = impact.sorted_namespaces();
assert_eq!(*sorted[0].0, "production");
assert_eq!(*sorted[0].1, 10);
}
#[test]
fn test_crd_deletion_impact_shared_policy() {
let impact = CrdDeletionImpact::empty("tests.example.com", CrdPolicy::Shared);
assert!(!impact.deletion_allowed);
assert!(impact.blocked_reason.is_some());
}
#[test]
fn test_deletion_impact_summary() {
let mut summary = DeletionImpactSummary::new();
let mut by_ns = HashMap::new();
by_ns.insert("default".to_string(), 5);
summary.add(CrdDeletionImpact::with_resources(
"first.example.com",
CrdPolicy::Managed,
by_ns,
));
summary.add(CrdDeletionImpact::empty(
"second.example.com",
CrdPolicy::Shared,
));
assert_eq!(summary.total_crds, 1);
assert_eq!(summary.total_resources, 5);
assert!(summary.has_blocked());
assert!(summary.has_data_loss());
assert_eq!(summary.blocked_crds, vec!["second.example.com"]);
}
#[test]
fn test_deletion_confirmation_not_required() {
let confirmation = DeletionConfirmation::not_required();
assert!(!confirmation.required);
assert!(confirmation.required_flags.is_empty());
}
#[test]
fn test_deletion_confirmation_from_impact_with_data_loss() {
let mut summary = DeletionImpactSummary::new();
let mut by_ns = HashMap::new();
by_ns.insert("production".to_string(), 10);
summary.add(CrdDeletionImpact::with_resources(
"tests.example.com",
CrdPolicy::Managed,
by_ns,
));
let confirmation = DeletionConfirmation::from_impact(&summary);
assert!(confirmation.required);
assert!(
confirmation
.required_flags
.contains(&"--delete-crds".to_string())
);
assert!(
confirmation
.required_flags
.contains(&"--confirm-crd-deletion".to_string())
);
}
#[test]
fn test_deletion_confirmation_from_impact_no_data_loss() {
let mut summary = DeletionImpactSummary::new();
summary.add(CrdDeletionImpact::empty(
"tests.example.com",
CrdPolicy::Managed,
));
let confirmation = DeletionConfirmation::from_impact(&summary);
assert!(confirmation.required);
assert!(
confirmation
.required_flags
.contains(&"--delete-crds".to_string())
);
assert!(
!confirmation
.required_flags
.contains(&"--confirm-crd-deletion".to_string())
);
}
#[test]
fn test_deletion_confirmation_from_blocked() {
let mut summary = DeletionImpactSummary::new();
summary.add(CrdDeletionImpact::empty(
"tests.example.com",
CrdPolicy::Shared,
));
let confirmation = DeletionConfirmation::from_impact(&summary);
assert!(confirmation.required);
assert!(confirmation.required_flags.is_empty()); assert!(confirmation.explanation.contains("blocked"));
}
#[test]
fn test_affected_namespaces() {
let mut summary = DeletionImpactSummary::new();
let mut by_ns1 = HashMap::new();
by_ns1.insert("production".to_string(), 5);
by_ns1.insert("staging".to_string(), 3);
summary.add(CrdDeletionImpact::with_resources(
"first.example.com",
CrdPolicy::Managed,
by_ns1,
));
let mut by_ns2 = HashMap::new();
by_ns2.insert("staging".to_string(), 2); by_ns2.insert("development".to_string(), 1);
summary.add(CrdDeletionImpact::with_resources(
"second.example.com",
CrdPolicy::Managed,
by_ns2,
));
let namespaces = summary.affected_namespaces();
assert_eq!(namespaces.len(), 3);
assert!(namespaces.contains(&"production".to_string()));
assert!(namespaces.contains(&"staging".to_string()));
assert!(namespaces.contains(&"development".to_string()));
}
}