Compare commits
6 Commits
45bf3cf265
...
snapshot-l
| Author | SHA1 | Date | |
|---|---|---|---|
| 83c1cc82b6 | |||
| 66d346a10c | |||
| 06a004a65d | |||
| 9d4e6acac0 | |||
| 4ff57062ae | |||
| 95cfc03518 |
@@ -1,6 +1,7 @@
|
|||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use harmony_macros::ip;
|
use harmony_macros::ip;
|
||||||
use harmony_types::{
|
use harmony_types::{
|
||||||
|
id::Id,
|
||||||
net::{MacAddress, Url},
|
net::{MacAddress, Url},
|
||||||
switch::PortLocation,
|
switch::PortLocation,
|
||||||
};
|
};
|
||||||
|
|||||||
182
harmony/src/infra/kube.rs
Normal file
182
harmony/src/infra/kube.rs
Normal file
@@ -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<DynamicObject> = 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<K>(res: &K) -> Result<DynamicObject, String>
|
||||||
|
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::<K>(),
|
||||||
|
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<K>() -> 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()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ pub mod executors;
|
|||||||
pub mod hp_ilo;
|
pub mod hp_ilo;
|
||||||
pub mod intel_amt;
|
pub mod intel_amt;
|
||||||
pub mod inventory;
|
pub mod inventory;
|
||||||
|
pub mod kube;
|
||||||
pub mod network_manager;
|
pub mod network_manager;
|
||||||
pub mod opnsense;
|
pub mod opnsense;
|
||||||
mod sqlx;
|
mod sqlx;
|
||||||
|
|||||||
@@ -135,8 +135,6 @@ impl OpenShiftNmStateNetworkManager {
|
|||||||
description: Some(format!("Member of bond {bond_name}")),
|
description: Some(format!("Member of bond {bond_name}")),
|
||||||
r#type: nmstate::InterfaceType::Ethernet,
|
r#type: nmstate::InterfaceType::Ethernet,
|
||||||
state: "up".to_string(),
|
state: "up".to_string(),
|
||||||
mtu: Some(switch_port.interface.mtu),
|
|
||||||
mac_address: Some(switch_port.interface.mac_address.to_string()),
|
|
||||||
ipv4: Some(nmstate::IpStackSpec {
|
ipv4: Some(nmstate::IpStackSpec {
|
||||||
enabled: Some(false),
|
enabled: Some(false),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
@@ -162,7 +160,7 @@ impl OpenShiftNmStateNetworkManager {
|
|||||||
|
|
||||||
interfaces.push(nmstate::Interface {
|
interfaces.push(nmstate::Interface {
|
||||||
name: bond_name.to_string(),
|
name: bond_name.to_string(),
|
||||||
description: Some(format!("Network bond for host {host}")),
|
description: Some(format!("HARMONY - Network bond for host {host}")),
|
||||||
r#type: nmstate::InterfaceType::Bond,
|
r#type: nmstate::InterfaceType::Bond,
|
||||||
state: "up".to_string(),
|
state: "up".to_string(),
|
||||||
copy_mac_from,
|
copy_mac_from,
|
||||||
@@ -241,7 +239,7 @@ impl OpenShiftNmStateNetworkManager {
|
|||||||
.and_then(|network_state| network_state.status.current_state.as_ref())
|
.and_then(|network_state| network_state.status.current_state.as_ref())
|
||||||
.map_or(&interfaces, |current_state| ¤t_state.interfaces)
|
.map_or(&interfaces, |current_state| ¤t_state.interfaces)
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|i| i.r#type == nmstate::InterfaceType::Bond && i.link_aggregation.is_some())
|
.filter(|i| i.r#type == nmstate::InterfaceType::Bond)
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
let used_ids: HashSet<u32> = existing_bonds
|
let used_ids: HashSet<u32> = existing_bonds
|
||||||
|
|||||||
@@ -417,6 +417,7 @@ pub struct EthernetSpec {
|
|||||||
#[serde(rename_all = "kebab-case")]
|
#[serde(rename_all = "kebab-case")]
|
||||||
pub struct BondSpec {
|
pub struct BondSpec {
|
||||||
pub mode: String,
|
pub mode: String,
|
||||||
|
#[serde(alias = "port")]
|
||||||
pub ports: Vec<String>,
|
pub ports: Vec<String>,
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub options: Option<BTreeMap<String, Value>>,
|
pub options: Option<BTreeMap<String, Value>>,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use harmony_types::id::Id;
|
use harmony_types::id::Id;
|
||||||
use log::{debug, info, warn};
|
use log::{info, warn};
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
@@ -77,12 +77,12 @@ impl HostNetworkConfigurationInterpret {
|
|||||||
topology.configure_bond(&config).await.map_err(|e| {
|
topology.configure_bond(&config).await.map_err(|e| {
|
||||||
InterpretError::new(format!("Failed to configure host network: {e}"))
|
InterpretError::new(format!("Failed to configure host network: {e}"))
|
||||||
})?;
|
})?;
|
||||||
// topology
|
topology
|
||||||
// .configure_port_channel(&config)
|
.configure_port_channel(&config)
|
||||||
// .await
|
.await
|
||||||
// .map_err(|e| {
|
.map_err(|e| {
|
||||||
// InterpretError::new(format!("Failed to configure host network: {e}"))
|
InterpretError::new(format!("Failed to configure host network: {e}"))
|
||||||
// })?;
|
})?;
|
||||||
} else if config.switch_ports.is_empty() {
|
} else if config.switch_ports.is_empty() {
|
||||||
info!(
|
info!(
|
||||||
"[Host {current_host}/{total_hosts}] No ports found for {} interfaces, skipping",
|
"[Host {current_host}/{total_hosts}] No ports found for {} interfaces, skipping",
|
||||||
@@ -150,13 +150,6 @@ impl HostNetworkConfigurationInterpret {
|
|||||||
];
|
];
|
||||||
|
|
||||||
for config in configs {
|
for config in configs {
|
||||||
let host = self
|
|
||||||
.score
|
|
||||||
.hosts
|
|
||||||
.iter()
|
|
||||||
.find(|h| h.id == config.host_id)
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
if config.switch_ports.is_empty() {
|
if config.switch_ports.is_empty() {
|
||||||
report.push(format!(
|
report.push(format!(
|
||||||
"⏭️ Host {}: SKIPPED (No matching switch ports found)",
|
"⏭️ Host {}: SKIPPED (No matching switch ports found)",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, PartialOrd, Ord)]
|
#[derive(Copy, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, PartialOrd, Ord)]
|
||||||
pub struct MacAddress(pub [u8; 6]);
|
pub struct MacAddress(pub [u8; 6]);
|
||||||
|
|
||||||
impl MacAddress {
|
impl MacAddress {
|
||||||
@@ -19,6 +19,14 @@ impl From<&MacAddress> for String {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Debug for MacAddress {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
f.debug_tuple("MacAddress")
|
||||||
|
.field(&String::from(self))
|
||||||
|
.finish()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl std::fmt::Display for MacAddress {
|
impl std::fmt::Display for MacAddress {
|
||||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
f.write_str(&String::from(self))
|
f.write_str(&String::from(self))
|
||||||
|
|||||||
Reference in New Issue
Block a user