diff --git a/harmony/src/infra/kube.rs b/harmony/src/infra/kube.rs new file mode 100644 index 0000000..9fb1247 --- /dev/null +++ b/harmony/src/infra/kube.rs @@ -0,0 +1,182 @@ +use k8s_openapi::Resource as K8sResource; +use kube::api::{ApiResource, DynamicObject, GroupVersionKind}; +use kube::core::TypeMeta; +use serde::Serialize; +use serde::de::DeserializeOwned; +use serde_json::Value; + +/// Convert a typed Kubernetes resource `K` into a `DynamicObject`. +/// +/// Requirements: +/// - `K` must be a k8s_openapi resource (provides static GVK via `Resource`). +/// - `K` must have standard Kubernetes shape (metadata + payload fields). +/// +/// Notes: +/// - We set `types` (apiVersion/kind) and copy `metadata`. +/// - We place the remaining top-level fields into `obj.data` as JSON. +/// - Scope is not encoded on the object itself; you still need the corresponding +/// `DynamicResource` (derived from K::group/version/kind) when constructing an Api. +/// +/// Example usage: +/// let dyn_obj = kube_resource_to_dynamic(secret)?; +/// let api: Api = Api::namespaced_with(client, "ns", &dr); +/// api.patch(&dyn_obj.name_any(), &PatchParams::apply("mgr"), &Patch::Apply(dyn_obj)).await?; +pub fn kube_resource_to_dynamic(res: &K) -> Result +where + K: K8sResource + Serialize + DeserializeOwned, +{ + // Serialize the typed resource to JSON so we can split metadata and payload + let mut v = serde_json::to_value(res).map_err(|e| format!("Failed to serialize : {e}"))?; + let obj = v + .as_object_mut() + .ok_or_else(|| "expected object JSON".to_string())?; + + // Extract and parse metadata into kube::core::ObjectMeta + let metadata_value = obj + .remove("metadata") + .ok_or_else(|| "missing metadata".to_string())?; + let metadata: kube::core::ObjectMeta = serde_json::from_value(metadata_value) + .map_err(|e| format!("Failed to deserialize : {e}"))?; + + // Name is required for DynamicObject::new; prefer metadata.name + let name = metadata + .name + .clone() + .ok_or_else(|| "metadata.name is required".to_string())?; + + // Remaining fields (spec/status/data/etc.) become the dynamic payload + let payload = Value::Object(obj.clone()); + + // Construct the DynamicObject + let mut dyn_obj = DynamicObject::new( + &name, + &ApiResource::from_gvk(&GroupVersionKind::gvk(K::GROUP, K::VERSION, K::KIND)), + ); + dyn_obj.types = Some(TypeMeta { + api_version: api_version_for::(), + kind: K::KIND.into(), + }); + + // Preserve namespace/labels/annotations/etc. + dyn_obj.metadata = metadata; + + // Attach payload + dyn_obj.data = payload; + + Ok(dyn_obj) +} + +/// Helper: compute apiVersion string ("group/version" or "v1" for core). +fn api_version_for() -> String +where + K: K8sResource, +{ + let group = K::GROUP; + let version = K::VERSION; + if group.is_empty() { + version.to_string() // core/v1 => "v1" + } else { + format!("{}/{}", group, version) + } +} +#[cfg(test)] +mod test { + use super::*; + use k8s_openapi::api::{ + apps::v1::{Deployment, DeploymentSpec}, + core::v1::{PodTemplateSpec, Secret}, + }; + use kube::api::ObjectMeta; + use pretty_assertions::assert_eq; + + #[test] + fn secret_to_dynamic_roundtrip() { + // Create a sample Secret resource + let mut secret = Secret { + metadata: ObjectMeta { + name: Some("my-secret".to_string()), + ..Default::default() + }, + type_: Some("kubernetes.io/service-account-token".to_string()), + ..Default::default() + }; + + // Convert to DynamicResource + let dynamic: DynamicObject = + kube_resource_to_dynamic(&secret).expect("Failed to convert Secret to DynamicResource"); + + // Serialize both the original and dynamic resources to Value + let original_value = serde_json::to_value(&secret).expect("Failed to serialize Secret"); + let dynamic_value = + serde_json::to_value(&dynamic).expect("Failed to serialize DynamicResource"); + + // Assert that they are identical + assert_eq!(original_value, dynamic_value); + + secret.metadata.namespace = Some("false".to_string()); + let modified_value = serde_json::to_value(&secret).expect("Failed to serialize Secret"); + assert_ne!(modified_value, dynamic_value); + } + + #[test] + fn deployment_to_dynamic_roundtrip() { + // Create a sample Deployment with nested structures + let mut deployment = Deployment { + metadata: ObjectMeta { + name: Some("my-deployment".to_string()), + labels: Some({ + let mut map = std::collections::BTreeMap::new(); + map.insert("app".to_string(), "nginx".to_string()); + map + }), + ..Default::default() + }, + spec: Some(DeploymentSpec { + replicas: Some(3), + selector: Default::default(), + template: PodTemplateSpec { + metadata: Some(ObjectMeta { + labels: Some({ + let mut map = std::collections::BTreeMap::new(); + map.insert("app".to_string(), "nginx".to_string()); + map + }), + ..Default::default() + }), + spec: Some(Default::default()), // PodSpec with empty containers for simplicity + }, + ..Default::default() + }), + ..Default::default() + }; + + let dynamic = kube_resource_to_dynamic(&deployment).expect("Failed to convert Deployment"); + + let original_value = serde_json::to_value(&deployment).unwrap(); + let dynamic_value = serde_json::to_value(&dynamic).unwrap(); + + assert_eq!(original_value, dynamic_value); + + assert_eq!( + dynamic.data.get("spec").unwrap().get("replicas").unwrap(), + 3 + ); + assert_eq!( + dynamic + .data + .get("spec") + .unwrap() + .get("template") + .unwrap() + .get("metadata") + .unwrap() + .get("labels") + .unwrap() + .get("app") + .unwrap() + .as_str() + .unwrap(), + "nginx".to_string() + ); + } +} diff --git a/harmony/src/infra/mod.rs b/harmony/src/infra/mod.rs index 203cf90..253176c 100644 --- a/harmony/src/infra/mod.rs +++ b/harmony/src/infra/mod.rs @@ -3,5 +3,6 @@ pub mod executors; pub mod hp_ilo; pub mod intel_amt; pub mod inventory; +pub mod kube; pub mod opnsense; mod sqlx;