use kube::{
Client,
api::{Api, DeleteParams, DynamicObject, Patch, PatchParams},
core::{GroupVersionKind, TypeMeta},
discovery::{ApiCapabilities, ApiResource, Discovery, Scope},
};
use crate::crd::ResourceCategory;
use crate::error::{KubeError, Result};
const FIELD_MANAGER: &str = "sherpack";
const RESOURCE_POLICY_ANNOTATION: &str = "helm.sh/resource-policy";
const RESOURCE_POLICY_KEEP: &str = "keep";
const SHERPACK_RESOURCE_POLICY: &str = "sherpack.io/resource-policy";
#[derive(Debug, Clone)]
pub struct ApplyResult {
pub kind: String,
pub name: String,
pub namespace: Option<String>,
pub created: bool,
}
#[derive(Debug, Clone)]
pub struct DeleteResult {
pub kind: String,
pub name: String,
pub namespace: Option<String>,
pub deleted: bool,
pub skip_reason: Option<String>,
}
#[derive(Debug, Clone, Default)]
pub struct OperationSummary {
pub succeeded: Vec<String>,
pub failed: Vec<(String, String)>,
pub skipped: Vec<(String, String)>,
}
impl OperationSummary {
pub fn is_success(&self) -> bool {
self.failed.is_empty()
}
pub fn total(&self) -> usize {
self.succeeded.len() + self.failed.len() + self.skipped.len()
}
pub fn summary(&self) -> String {
let mut parts = Vec::with_capacity(3); if !self.succeeded.is_empty() {
parts.push(format!("{} succeeded", self.succeeded.len()));
}
if !self.failed.is_empty() {
parts.push(format!("{} failed", self.failed.len()));
}
if !self.skipped.is_empty() {
parts.push(format!("{} skipped", self.skipped.len()));
}
if parts.is_empty() {
"No resources processed".to_string()
} else {
parts.join(", ")
}
}
}
#[derive(Debug, Clone)]
struct ParsedResource {
obj: DynamicObject,
gvk: GroupVersionKind,
api_resource: ApiResource,
capabilities: ApiCapabilities,
}
impl ParsedResource {
fn display_name(&self) -> String {
let name = self.obj.metadata.name.as_deref().unwrap_or("unnamed");
match &self.obj.metadata.namespace {
Some(ns) => format!("{}/{}/{}", ns, self.gvk.kind, name),
None => format!("{}/{}", self.gvk.kind, name),
}
}
fn has_keep_policy(&self) -> bool {
self.obj
.metadata
.annotations
.as_ref()
.map(|annotations| {
annotations
.get(RESOURCE_POLICY_ANNOTATION)
.map(String::as_str)
== Some(RESOURCE_POLICY_KEEP)
|| annotations
.get(SHERPACK_RESOURCE_POLICY)
.map(String::as_str)
== Some(RESOURCE_POLICY_KEEP)
})
.unwrap_or(false)
}
}
pub struct ResourceManager {
client: Client,
discovery: Discovery,
}
impl ResourceManager {
pub async fn new(client: Client) -> Result<Self> {
let discovery = Discovery::new(client.clone())
.run()
.await
.map_err(KubeError::Api)?;
Ok(Self { client, discovery })
}
pub fn with_discovery(client: Client, discovery: Discovery) -> Self {
Self { client, discovery }
}
pub async fn refresh_discovery(&mut self) -> Result<()> {
self.discovery = Discovery::new(self.client.clone())
.run()
.await
.map_err(KubeError::Api)?;
Ok(())
}
pub async fn apply_manifest(
&self,
namespace: &str,
manifest: &str,
dry_run: bool,
) -> Result<OperationSummary> {
let resources = self.parse_manifest(manifest, namespace)?;
self.apply_resources(&resources, dry_run).await
}
pub async fn delete_manifest(
&self,
namespace: &str,
manifest: &str,
dry_run: bool,
) -> Result<OperationSummary> {
let resources = self.parse_manifest(manifest, namespace)?;
self.delete_resources(&resources, dry_run).await
}
fn parse_manifest(
&self,
manifest: &str,
default_namespace: &str,
) -> Result<Vec<ParsedResource>> {
let mut resources = Vec::new();
for (index, doc) in manifest.split("---").enumerate() {
let doc = doc.trim();
if doc.is_empty() {
continue;
}
if doc
.lines()
.all(|l| l.trim().is_empty() || l.trim().starts_with('#'))
{
continue;
}
match self.parse_single_document(doc, default_namespace) {
Ok(resource) => resources.push(resource),
Err(e) => {
return Err(KubeError::InvalidConfig(format!(
"Failed to parse document {}: {}",
index, e
)));
}
}
}
Ok(resources)
}
fn parse_single_document(&self, doc: &str, default_namespace: &str) -> Result<ParsedResource> {
let mut obj: DynamicObject = serde_yaml::from_str(doc)
.map_err(|e| KubeError::Serialization(format!("YAML parse error: {}", e)))?;
let type_meta = obj.types.as_ref().ok_or_else(|| {
KubeError::InvalidConfig("Resource missing apiVersion or kind".to_string())
})?;
let gvk = gvk_from_type_meta(type_meta);
let (api_resource, capabilities) = self.discovery.resolve_gvk(&gvk).ok_or_else(|| {
KubeError::InvalidConfig(format!(
"Unknown resource type: {}/{}",
type_meta.api_version, type_meta.kind
))
})?;
if capabilities.scope == Scope::Namespaced && obj.metadata.namespace.is_none() {
obj.metadata.namespace = Some(default_namespace.to_string());
}
Ok(ParsedResource {
obj,
gvk,
api_resource,
capabilities,
})
}
async fn apply_resources(
&self,
resources: &[ParsedResource],
dry_run: bool,
) -> Result<OperationSummary> {
let mut summary = OperationSummary::default();
let sorted = self.sort_for_apply(resources);
for resource in sorted {
let name = resource.display_name();
match self.apply_single_resource(resource, dry_run).await {
Ok(result) => {
let action = if result.created {
"created"
} else {
"configured"
};
summary.succeeded.push(format!("{} ({})", name, action));
}
Err(e) => {
summary.failed.push((name, e.to_string()));
}
}
}
Ok(summary)
}
async fn apply_single_resource(
&self,
resource: &ParsedResource,
dry_run: bool,
) -> Result<ApplyResult> {
let name = resource.obj.metadata.name.as_deref().ok_or_else(|| {
KubeError::InvalidConfig("Resource missing metadata.name".to_string())
})?;
let api = self.api_for_resource(resource);
let exists = api.get_opt(name).await.map_err(KubeError::Api)?.is_some();
let mut params = PatchParams::apply(FIELD_MANAGER);
params.force = true;
if dry_run {
params.dry_run = true;
}
let _result = api
.patch(name, ¶ms, &Patch::Apply(&resource.obj))
.await
.map_err(|e| {
KubeError::InvalidConfig(format!(
"Failed to apply {}: {}",
resource.display_name(),
e
))
})?;
Ok(ApplyResult {
kind: resource.gvk.kind.clone(),
name: name.to_string(),
namespace: resource.obj.metadata.namespace.clone(),
created: !exists,
})
}
async fn delete_resources(
&self,
resources: &[ParsedResource],
dry_run: bool,
) -> Result<OperationSummary> {
let mut summary = OperationSummary::default();
let sorted = self.sort_for_delete(resources);
for resource in sorted {
let name = resource.display_name();
if resource.has_keep_policy() {
summary
.skipped
.push((name, "resource-policy: keep".to_string()));
continue;
}
match self.delete_single_resource(resource, dry_run).await {
Ok(result) => {
if result.deleted {
summary.succeeded.push(format!("{} (deleted)", name));
} else if let Some(reason) = result.skip_reason {
summary.skipped.push((name, reason));
}
}
Err(e) => {
if e.is_not_found() {
summary.skipped.push((name, "not found".to_string()));
} else {
summary.failed.push((name, e.to_string()));
}
}
}
}
Ok(summary)
}
async fn delete_single_resource(
&self,
resource: &ParsedResource,
dry_run: bool,
) -> Result<DeleteResult> {
let name = resource.obj.metadata.name.as_deref().ok_or_else(|| {
KubeError::InvalidConfig("Resource missing metadata.name".to_string())
})?;
let api = self.api_for_resource(resource);
let params = DeleteParams {
propagation_policy: Some(kube::api::PropagationPolicy::Background),
dry_run,
..Default::default()
};
match api.delete(name, ¶ms).await {
Ok(_) => Ok(DeleteResult {
kind: resource.gvk.kind.clone(),
name: name.to_string(),
namespace: resource.obj.metadata.namespace.clone(),
deleted: true,
skip_reason: None,
}),
Err(kube::Error::Api(resp)) if resp.code == 404 => Ok(DeleteResult {
kind: resource.gvk.kind.clone(),
name: name.to_string(),
namespace: resource.obj.metadata.namespace.clone(),
deleted: false,
skip_reason: Some("not found".to_string()),
}),
Err(e) => Err(KubeError::Api(e)),
}
}
fn sort_for_apply<'a>(&self, resources: &'a [ParsedResource]) -> Vec<&'a ParsedResource> {
let mut sorted: Vec<&ParsedResource> = resources.iter().collect();
sorted.sort_by(|a, b| {
let cat_a = ResourceCategory::from_resource(
&a.gvk.kind,
a.obj
.types
.as_ref()
.map(|t| t.api_version.as_str())
.unwrap_or("v1"),
);
let cat_b = ResourceCategory::from_resource(
&b.gvk.kind,
b.obj
.types
.as_ref()
.map(|t| t.api_version.as_str())
.unwrap_or("v1"),
);
cat_a.cmp(&cat_b)
});
sorted
}
fn sort_for_delete<'a>(&self, resources: &'a [ParsedResource]) -> Vec<&'a ParsedResource> {
let mut sorted: Vec<&ParsedResource> = resources.iter().collect();
sorted.sort_by(|a, b| {
let cat_a = ResourceCategory::from_resource(
&a.gvk.kind,
a.obj
.types
.as_ref()
.map(|t| t.api_version.as_str())
.unwrap_or("v1"),
);
let cat_b = ResourceCategory::from_resource(
&b.gvk.kind,
b.obj
.types
.as_ref()
.map(|t| t.api_version.as_str())
.unwrap_or("v1"),
);
cat_b.cmp(&cat_a) });
sorted
}
pub fn is_crd(kind: &str) -> bool {
kind == "CustomResourceDefinition"
}
#[allow(dead_code)]
fn partition_crds<'a>(
&self,
resources: &'a [ParsedResource],
) -> (Vec<&'a ParsedResource>, Vec<&'a ParsedResource>) {
resources.iter().partition(|r| Self::is_crd(&r.gvk.kind))
}
fn api_for_resource(&self, resource: &ParsedResource) -> Api<DynamicObject> {
if resource.capabilities.scope == Scope::Namespaced {
let ns = resource
.obj
.metadata
.namespace
.as_deref()
.unwrap_or("default");
Api::namespaced_with(self.client.clone(), ns, &resource.api_resource)
} else {
Api::all_with(self.client.clone(), &resource.api_resource)
}
}
}
fn gvk_from_type_meta(tm: &TypeMeta) -> GroupVersionKind {
let (group, version) = match tm.api_version.rsplit_once('/') {
Some((g, v)) => (g.to_string(), v.to_string()),
None => (String::new(), tm.api_version.clone()),
};
GroupVersionKind {
group,
version,
kind: tm.kind.clone(),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[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);
}
#[test]
fn test_resource_manager_is_crd() {
assert!(ResourceManager::is_crd("CustomResourceDefinition"));
assert!(!ResourceManager::is_crd("Deployment"));
assert!(!ResourceManager::is_crd("ConfigMap"));
}
#[test]
fn test_gvk_from_type_meta() {
let tm = TypeMeta {
api_version: "apps/v1".to_string(),
kind: "Deployment".to_string(),
};
let gvk = gvk_from_type_meta(&tm);
assert_eq!(gvk.group, "apps");
assert_eq!(gvk.version, "v1");
assert_eq!(gvk.kind, "Deployment");
let tm_core = TypeMeta {
api_version: "v1".to_string(),
kind: "ConfigMap".to_string(),
};
let gvk_core = gvk_from_type_meta(&tm_core);
assert_eq!(gvk_core.group, "");
assert_eq!(gvk_core.version, "v1");
assert_eq!(gvk_core.kind, "ConfigMap");
}
#[test]
fn test_gvk_from_type_meta_various_api_groups() {
let tm = TypeMeta {
api_version: "networking.k8s.io/v1".to_string(),
kind: "Ingress".to_string(),
};
let gvk = gvk_from_type_meta(&tm);
assert_eq!(gvk.group, "networking.k8s.io");
assert_eq!(gvk.version, "v1");
let tm_batch = TypeMeta {
api_version: "batch/v1".to_string(),
kind: "Job".to_string(),
};
let gvk_batch = gvk_from_type_meta(&tm_batch);
assert_eq!(gvk_batch.group, "batch");
assert_eq!(gvk_batch.version, "v1");
let tm_hpa = TypeMeta {
api_version: "autoscaling/v2".to_string(),
kind: "HorizontalPodAutoscaler".to_string(),
};
let gvk_hpa = gvk_from_type_meta(&tm_hpa);
assert_eq!(gvk_hpa.group, "autoscaling");
assert_eq!(gvk_hpa.version, "v2");
}
#[test]
fn test_operation_summary() {
let mut summary = OperationSummary::default();
summary.succeeded.push("deployment/nginx".to_string());
summary.succeeded.push("service/nginx".to_string());
summary.skipped.push((
"secret/keep-me".to_string(),
"resource-policy: keep".to_string(),
));
assert!(summary.is_success());
assert_eq!(summary.total(), 3);
assert!(summary.summary().contains("2 succeeded"));
assert!(summary.summary().contains("1 skipped"));
}
#[test]
fn test_operation_summary_empty() {
let summary = OperationSummary::default();
assert!(summary.is_success());
assert_eq!(summary.total(), 0);
assert_eq!(summary.summary(), "No resources processed");
}
#[test]
fn test_operation_summary_with_failures() {
let mut summary = OperationSummary::default();
summary.succeeded.push("deployment/app".to_string());
summary.failed.push((
"service/broken".to_string(),
"Connection refused".to_string(),
));
assert!(!summary.is_success());
assert_eq!(summary.total(), 2);
assert!(summary.summary().contains("1 succeeded"));
assert!(summary.summary().contains("1 failed"));
}
#[test]
fn test_operation_summary_only_skipped() {
let mut summary = OperationSummary::default();
summary
.skipped
.push(("secret/keep".to_string(), "keep policy".to_string()));
summary
.skipped
.push(("configmap/keep".to_string(), "keep policy".to_string()));
assert!(summary.is_success());
assert_eq!(summary.total(), 2);
assert!(summary.summary().contains("2 skipped"));
}
#[test]
fn test_apply_result_created() {
let result = ApplyResult {
kind: "Deployment".to_string(),
name: "my-app".to_string(),
namespace: Some("default".to_string()),
created: true,
};
assert!(result.created);
assert_eq!(result.kind, "Deployment");
assert_eq!(result.namespace, Some("default".to_string()));
}
#[test]
fn test_apply_result_updated() {
let result = ApplyResult {
kind: "ConfigMap".to_string(),
name: "config".to_string(),
namespace: Some("default".to_string()),
created: false,
};
assert!(!result.created);
}
#[test]
fn test_delete_result_deleted() {
let result = DeleteResult {
kind: "Pod".to_string(),
name: "old-pod".to_string(),
namespace: Some("default".to_string()),
deleted: true,
skip_reason: None,
};
assert!(result.deleted);
assert!(result.skip_reason.is_none());
}
#[test]
fn test_delete_result_skipped() {
let result = DeleteResult {
kind: "Secret".to_string(),
name: "important-secret".to_string(),
namespace: Some("default".to_string()),
deleted: false,
skip_reason: Some("resource-policy: keep".to_string()),
};
assert!(!result.deleted);
assert_eq!(
result.skip_reason,
Some("resource-policy: keep".to_string())
);
}
#[test]
fn test_delete_result_cluster_scoped() {
let result = DeleteResult {
kind: "ClusterRole".to_string(),
name: "admin-role".to_string(),
namespace: None,
deleted: true,
skip_reason: None,
};
assert!(result.namespace.is_none());
}
#[test]
fn test_resource_policy_annotation_names() {
assert_eq!(RESOURCE_POLICY_ANNOTATION, "helm.sh/resource-policy");
assert_eq!(SHERPACK_RESOURCE_POLICY, "sherpack.io/resource-policy");
assert_eq!(RESOURCE_POLICY_KEEP, "keep");
}
#[test]
fn test_field_manager_constant() {
assert_eq!(FIELD_MANAGER, "sherpack");
}
}