From 95cfc03518e90095bbc3ae594bb1f07136554a7b Mon Sep 17 00:00:00 2001 From: Jean-Gabriel Gill-Couture Date: Wed, 29 Oct 2025 17:24:35 -0400 Subject: [PATCH 1/2] feat(kube): Utility function to convert kube_openapi Resource to DynamicObject. This will allow initializing resources strongly typed and then bundle various types into a list of DynamicObject --- harmony/src/infra/kube.rs | 182 ++++++++++++++++++++++++++++++++++++++ harmony/src/infra/mod.rs | 1 + 2 files changed, 183 insertions(+) create mode 100644 harmony/src/infra/kube.rs 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; From 9d4e6acac0eb1027b39ba522c680e6bbc5f23668 Mon Sep 17 00:00:00 2001 From: Ian Letourneau Date: Wed, 5 Nov 2025 23:38:24 +0000 Subject: [PATCH 2/2] fix(host_network): retrieve proper hostname and next available bond id (#182) In order to query the current network state `NodeNetworkState` and to apply a `NodeNetworkConfigurationPolicy` for a given node, we first needed to find its hostname. As all we had was the UUID of a node. We had different options available (e.g. updating the Harmony Inventory Agent to retrieve it, store it in the OKD installation pipeline on assignation, etc.). But for the sake of simplicity and for better flexibility (e.g. being able to run this score on a cluster that wasn't setup with Harmony), the `hostname` was retrieved directly in the cluster by running the equivalent of `kubectl get nodes -o yaml` and matching the nodes with the system UUID. ### Other changes * Find the next available bond id for a node * Apply a network config policy for a node (configuring a bond in our case) * Adjust the CRDs for NMState Note: to see a quick demo, watch the recording in https://git.nationtech.io/NationTech/harmony/pulls/183 Reviewed-on: https://git.nationtech.io/NationTech/harmony/pulls/182 Reviewed-by: johnride --- harmony/src/domain/topology/ha_cluster.rs | 133 +++++++-- harmony/src/domain/topology/k8s.rs | 59 +++- harmony/src/modules/inventory/discovery.rs | 11 +- harmony/src/modules/okd/crd/nmstate.rs | 305 +++++++++++++++++++-- 4 files changed, 447 insertions(+), 61 deletions(-) diff --git a/harmony/src/domain/topology/ha_cluster.rs b/harmony/src/domain/topology/ha_cluster.rs index d65d9aa..8f65e53 100644 --- a/harmony/src/domain/topology/ha_cluster.rs +++ b/harmony/src/domain/topology/ha_cluster.rs @@ -1,10 +1,15 @@ use async_trait::async_trait; use harmony_macros::ip; use harmony_types::{ + id::Id, net::{MacAddress, Url}, switch::PortLocation, }; -use kube::api::ObjectMeta; +use k8s_openapi::api::core::v1::Node; +use kube::{ + ResourceExt, + api::{ObjectList, ObjectMeta}, +}; use log::debug; use log::info; @@ -22,7 +27,7 @@ use super::{ Topology, k8s::K8sClient, }; -use std::collections::BTreeMap; +use std::collections::{BTreeMap, HashSet}; use std::sync::Arc; #[derive(Debug, Clone)] @@ -63,7 +68,7 @@ impl K8sclient for HAClusterTopology { K8sClient::try_default().await.map_err(|e| e.to_string())?, )), Some(kubeconfig) => { - let Some(client) = K8sClient::from_kubeconfig(&kubeconfig).await else { + let Some(client) = K8sClient::from_kubeconfig(kubeconfig).await else { return Err("Failed to create k8s client".to_string()); }; Ok(Arc::new(client)) @@ -143,7 +148,10 @@ impl HAClusterTopology { }, ..Default::default() }; - debug!("Creating NMState: {nmstate:#?}"); + debug!( + "Creating NMState:\n{}", + serde_yaml::to_string(&nmstate).unwrap() + ); k8s_client .apply(&nmstate, None) .await @@ -152,10 +160,6 @@ impl HAClusterTopology { Ok(()) } - fn get_next_bond_id(&self) -> u8 { - 42 // FIXME: Find a better way to declare the bond id - } - async fn configure_bond(&self, config: &HostNetworkConfig) -> Result<(), SwitchError> { self.ensure_nmstate_operator_installed() .await @@ -165,9 +169,23 @@ impl HAClusterTopology { )) })?; - let bond_config = self.create_bond_configuration(config); + let hostname = self.get_hostname(&config.host_id).await.map_err(|e| { + SwitchError::new(format!( + "Can't configure bond, can't get hostname for host '{}': {e}", + config.host_id + )) + })?; + let bond_id = self.get_next_bond_id(&hostname).await.map_err(|e| { + SwitchError::new(format!( + "Can't configure bond, can't get an available bond id for host '{}': {e}", + config.host_id + )) + })?; + let bond_config = self.create_bond_configuration(&hostname, &bond_id, config); + debug!( - "Applying NMState bond config for host {}: {bond_config:#?}", + "Applying NMState bond config for host {}:\n{}", + serde_yaml::to_string(&bond_config).unwrap(), config.host_id ); self.k8s_client() @@ -182,26 +200,24 @@ impl HAClusterTopology { fn create_bond_configuration( &self, + host: &str, + bond_name: &str, config: &HostNetworkConfig, ) -> NodeNetworkConfigurationPolicy { - let host_name = &config.host_id; - let bond_id = self.get_next_bond_id(); - let bond_name = format!("bond{bond_id}"); - - info!("Configuring bond '{bond_name}' for host '{host_name}'..."); + info!("Configuring bond '{bond_name}' for host '{host}'..."); let mut bond_mtu: Option = None; let mut copy_mac_from: Option = None; let mut bond_ports = Vec::new(); - let mut interfaces: Vec = Vec::new(); + let mut interfaces: Vec = Vec::new(); for switch_port in &config.switch_ports { let interface_name = switch_port.interface.name.clone(); - interfaces.push(nmstate::InterfaceSpec { + interfaces.push(nmstate::Interface { name: interface_name.clone(), description: Some(format!("Member of bond {bond_name}")), - r#type: "ethernet".to_string(), + r#type: nmstate::InterfaceType::Ethernet, state: "up".to_string(), mtu: Some(switch_port.interface.mtu), mac_address: Some(switch_port.interface.mac_address.to_string()), @@ -228,10 +244,10 @@ impl HAClusterTopology { } } - interfaces.push(nmstate::InterfaceSpec { - name: bond_name.clone(), - description: Some(format!("Network bond for host {host_name}")), - r#type: "bond".to_string(), + interfaces.push(nmstate::Interface { + name: bond_name.to_string(), + description: Some(format!("Network bond for host {host}")), + r#type: nmstate::InterfaceType::Bond, state: "up".to_string(), copy_mac_from, ipv4: Some(nmstate::IpStackSpec { @@ -255,19 +271,80 @@ impl HAClusterTopology { NodeNetworkConfigurationPolicy { metadata: ObjectMeta { - name: Some(format!("{host_name}-bond-config")), + name: Some(format!("{host}-bond-config")), ..Default::default() }, spec: NodeNetworkConfigurationPolicySpec { node_selector: Some(BTreeMap::from([( "kubernetes.io/hostname".to_string(), - host_name.to_string(), + host.to_string(), )])), - desired_state: nmstate::DesiredStateSpec { interfaces }, + desired_state: nmstate::NetworkState { + interfaces, + ..Default::default() + }, }, } } + async fn get_hostname(&self, host_id: &Id) -> Result { + let nodes: ObjectList = self + .k8s_client() + .await + .unwrap() + .list_resources(None, None) + .await + .map_err(|e| format!("Failed to list nodes: {e}"))?; + + let Some(node) = nodes.iter().find(|n| { + n.status + .as_ref() + .and_then(|s| s.node_info.as_ref()) + .map(|i| i.system_uuid == host_id.to_string()) + .unwrap_or(false) + }) else { + return Err(format!("No node found for host '{host_id}'")); + }; + + node.labels() + .get("kubernetes.io/hostname") + .ok_or(format!( + "Node '{host_id}' has no kubernetes.io/hostname label" + )) + .cloned() + } + + async fn get_next_bond_id(&self, hostname: &str) -> Result { + let network_state: Option = self + .k8s_client() + .await + .unwrap() + .get_resource(hostname, None) + .await + .map_err(|e| format!("Failed to list nodes: {e}"))?; + + let interfaces = vec![]; + let existing_bonds: Vec<&nmstate::Interface> = network_state + .as_ref() + .and_then(|network_state| network_state.status.current_state.as_ref()) + .map_or(&interfaces, |current_state| ¤t_state.interfaces) + .iter() + .filter(|i| i.r#type == nmstate::InterfaceType::Bond) + .collect(); + + let used_ids: HashSet = existing_bonds + .iter() + .filter_map(|i| { + i.name + .strip_prefix("bond") + .and_then(|id| id.parse::().ok()) + }) + .collect(); + + let next_id = (0..).find(|id| !used_ids.contains(id)).unwrap(); + Ok(format!("bond{next_id}")) + } + async fn configure_port_channel(&self, config: &HostNetworkConfig) -> Result<(), SwitchError> { debug!("Configuring port channel: {config:#?}"); let switch_ports = config.switch_ports.iter().map(|s| s.port.clone()).collect(); @@ -458,16 +535,14 @@ impl HttpServer for HAClusterTopology { #[async_trait] impl Switch for HAClusterTopology { async fn setup_switch(&self) -> Result<(), SwitchError> { - self.switch_client.setup().await?; - Ok(()) + self.switch_client.setup().await.map(|_| ()) } async fn get_port_for_mac_address( &self, mac_address: &MacAddress, ) -> Result, SwitchError> { - let port = self.switch_client.find_port(mac_address).await?; - Ok(port) + self.switch_client.find_port(mac_address).await } async fn configure_host_network(&self, config: &HostNetworkConfig) -> Result<(), SwitchError> { diff --git a/harmony/src/domain/topology/k8s.rs b/harmony/src/domain/topology/k8s.rs index 71129e1..03c59e1 100644 --- a/harmony/src/domain/topology/k8s.rs +++ b/harmony/src/domain/topology/k8s.rs @@ -5,13 +5,15 @@ use k8s_openapi::{ ClusterResourceScope, NamespaceResourceScope, api::{ apps::v1::Deployment, - core::v1::{Pod, ServiceAccount}, + core::v1::{Node, Pod, ServiceAccount}, }, apimachinery::pkg::version::Info, }; use kube::{ Client, Config, Discovery, Error, Resource, - api::{Api, AttachParams, DeleteParams, ListParams, Patch, PatchParams, ResourceExt}, + api::{ + Api, AttachParams, DeleteParams, ListParams, ObjectList, Patch, PatchParams, ResourceExt, + }, config::{KubeConfigOptions, Kubeconfig}, core::ErrorResponse, discovery::{ApiCapabilities, Scope}, @@ -564,7 +566,58 @@ impl K8sClient { Ok(()) } - pub(crate) async fn from_kubeconfig(path: &str) -> Option { + /// Gets a single named resource of a specific type `K`. + /// + /// This function uses the `ApplyStrategy` trait to correctly determine + /// whether to look in a specific namespace or in the entire cluster. + /// + /// Returns `Ok(None)` if the resource is not found (404). + pub async fn get_resource( + &self, + name: &str, + namespace: Option<&str>, + ) -> Result, Error> + where + K: Resource + Clone + std::fmt::Debug + DeserializeOwned, + ::Scope: ApplyStrategy, + ::DynamicType: Default, + { + let api: Api = + <::Scope as ApplyStrategy>::get_api(&self.client, namespace); + + api.get_opt(name).await + } + + /// Lists all resources of a specific type `K`. + /// + /// This function uses the `ApplyStrategy` trait to correctly determine + /// whether to list from a specific namespace or from the entire cluster. + pub async fn list_resources( + &self, + namespace: Option<&str>, + list_params: Option, + ) -> Result, Error> + where + K: Resource + Clone + std::fmt::Debug + DeserializeOwned, + ::Scope: ApplyStrategy, + ::DynamicType: Default, + { + let api: Api = + <::Scope as ApplyStrategy>::get_api(&self.client, namespace); + + let list_params = list_params.unwrap_or_default(); + api.list(&list_params).await + } + + /// Fetches a list of all Nodes in the cluster. + pub async fn get_nodes( + &self, + list_params: Option, + ) -> Result, Error> { + self.list_resources(None, list_params).await + } + + pub async fn from_kubeconfig(path: &str) -> Option { let k = match Kubeconfig::read_from(path) { Ok(k) => k, Err(e) => { diff --git a/harmony/src/modules/inventory/discovery.rs b/harmony/src/modules/inventory/discovery.rs index 143c56a..b02078b 100644 --- a/harmony/src/modules/inventory/discovery.rs +++ b/harmony/src/modules/inventory/discovery.rs @@ -74,7 +74,11 @@ impl Interpret for DiscoverHostForRoleInterpret { match ans { Ok(choice) => { - info!("Selected {} as the bootstrap node.", choice.summary()); + info!( + "Selected {} as the {:?} node.", + choice.summary(), + self.score.role + ); host_repo .save_role_mapping(&self.score.role, &choice) .await?; @@ -90,10 +94,7 @@ impl Interpret for DiscoverHostForRoleInterpret { "Failed to select node for role {:?} : {}", self.score.role, e ); - return Err(InterpretError::new(format!( - "Could not select host : {}", - e.to_string() - ))); + return Err(InterpretError::new(format!("Could not select host : {e}"))); } } } diff --git a/harmony/src/modules/okd/crd/nmstate.rs b/harmony/src/modules/okd/crd/nmstate.rs index 9f986e5..f0eb4ae 100644 --- a/harmony/src/modules/okd/crd/nmstate.rs +++ b/harmony/src/modules/okd/crd/nmstate.rs @@ -1,6 +1,7 @@ use std::collections::BTreeMap; -use kube::CustomResource; +use k8s_openapi::{ClusterResourceScope, Resource}; +use kube::{CustomResource, api::ObjectMeta}; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use serde_json::Value; @@ -47,28 +48,223 @@ pub struct ProbeDns { group = "nmstate.io", version = "v1", kind = "NodeNetworkConfigurationPolicy", - namespaced + namespaced = false )] #[serde(rename_all = "camelCase")] pub struct NodeNetworkConfigurationPolicySpec { #[serde(skip_serializing_if = "Option::is_none")] pub node_selector: Option>, - pub desired_state: DesiredStateSpec, + pub desired_state: NetworkState, +} + +// Currently, kube-rs derive doesn't support resources without a `spec` field, so we have +// to implement it ourselves. +// +// Ref: +// - https://github.com/kube-rs/kube/issues/1763 +// - https://github.com/kube-rs/kube/discussions/1762 +#[derive(Deserialize, Serialize, Clone, Debug)] +#[serde(rename_all = "camelCase")] +pub struct NodeNetworkState { + metadata: ObjectMeta, + pub status: NodeNetworkStateStatus, +} + +impl Resource for NodeNetworkState { + const API_VERSION: &'static str = "nmstate.io/v1beta1"; + const GROUP: &'static str = "nmstate.io"; + const VERSION: &'static str = "v1beta1"; + const KIND: &'static str = "NodeNetworkState"; + const URL_PATH_SEGMENT: &'static str = "nodenetworkstates"; + type Scope = ClusterResourceScope; +} + +impl k8s_openapi::Metadata for NodeNetworkState { + type Ty = ObjectMeta; + + fn metadata(&self) -> &Self::Ty { + &self.metadata + } + + fn metadata_mut(&mut self) -> &mut Self::Ty { + &mut self.metadata + } } #[derive(Deserialize, Serialize, Clone, Debug, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct NodeNetworkStateStatus { + #[serde(skip_serializing_if = "Option::is_none")] + pub current_state: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub handler_nmstate_version: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub host_network_manager_version: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub last_successful_update_time: Option, +} + +/// The NetworkState is the top-level struct, representing the entire +/// desired or current network state. +#[derive(Deserialize, Serialize, Clone, Debug, Default, JsonSchema)] #[serde(rename_all = "kebab-case")] -pub struct DesiredStateSpec { - pub interfaces: Vec, +#[serde(deny_unknown_fields)] +pub struct NetworkState { + #[serde(skip_serializing_if = "Option::is_none")] + pub hostname: Option, + #[serde(rename = "dns-resolver", skip_serializing_if = "Option::is_none")] + pub dns: Option, + #[serde(rename = "route-rules", skip_serializing_if = "Option::is_none")] + pub rules: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub routes: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub interfaces: Vec, + #[serde(rename = "ovs-db", skip_serializing_if = "Option::is_none")] + pub ovsdb: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub ovn: Option, } #[derive(Deserialize, Serialize, Clone, Debug, Default, JsonSchema)] #[serde(rename_all = "kebab-case")] -pub struct InterfaceSpec { +pub struct HostNameState { + #[serde(skip_serializing_if = "Option::is_none")] + pub running: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub config: Option, +} + +#[derive(Deserialize, Serialize, Clone, Debug, Default, JsonSchema)] +#[serde(rename_all = "kebab-case")] +pub struct DnsState { + #[serde(skip_serializing_if = "Option::is_none")] + pub running: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub config: Option, +} + +#[derive(Deserialize, Serialize, Clone, Debug, Default, JsonSchema)] +#[serde(rename_all = "kebab-case")] +pub struct DnsResolverConfig { + #[serde(skip_serializing_if = "Option::is_none")] + pub search: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub server: Option>, +} + +#[derive(Deserialize, Serialize, Clone, Debug, Default, JsonSchema)] +#[serde(rename_all = "kebab-case")] +pub struct RouteRuleState { + #[serde(skip_serializing_if = "Option::is_none")] + pub config: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub running: Option>, +} + +#[derive(Deserialize, Serialize, Clone, Debug, Default, JsonSchema)] +#[serde(rename_all = "kebab-case")] +pub struct RouteState { + #[serde(skip_serializing_if = "Option::is_none")] + pub config: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub running: Option>, +} + +#[derive(Deserialize, Serialize, Clone, Debug, Default, JsonSchema)] +#[serde(rename_all = "kebab-case")] +pub struct RouteRule { + #[serde(rename = "ip-from", skip_serializing_if = "Option::is_none")] + pub ip_from: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub priority: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub route_table: Option, +} + +#[derive(Deserialize, Serialize, Clone, Debug, Default, JsonSchema)] +#[serde(rename_all = "kebab-case")] +pub struct Route { + #[serde(skip_serializing_if = "Option::is_none")] + pub destination: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub metric: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub next_hop_address: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub next_hop_interface: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub table_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub mtu: Option, +} + +#[derive(Deserialize, Serialize, Clone, Debug, Default, JsonSchema)] +#[serde(rename_all = "kebab-case")] +pub struct OvsDbGlobalConfig { + #[serde(skip_serializing_if = "Option::is_none")] + pub external_ids: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub other_config: Option>, +} + +#[derive(Deserialize, Serialize, Clone, Debug, Default, JsonSchema)] +#[serde(rename_all = "kebab-case")] +pub struct OvnConfiguration { + #[serde(skip_serializing_if = "Option::is_none")] + pub bridge_mappings: Option>, +} + +#[derive(Deserialize, Serialize, Clone, Debug, Default, JsonSchema)] +#[serde(rename_all = "kebab-case")] +pub struct OvnBridgeMapping { + #[serde(skip_serializing_if = "Option::is_none")] + pub localnet: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub bridge: Option, +} + +#[derive(Deserialize, Serialize, Clone, Debug, JsonSchema)] +#[serde(untagged)] +#[serde(rename_all = "kebab-case")] +pub enum StpSpec { + Bool(bool), + Options(StpOptions), +} + +#[derive(Deserialize, Serialize, Clone, Debug, JsonSchema)] +#[serde(rename_all = "kebab-case")] +pub struct LldpState { + #[serde(skip_serializing_if = "Option::is_none")] + pub enabled: Option, +} + +#[derive(Deserialize, Serialize, Clone, Debug, JsonSchema)] +#[serde(rename_all = "kebab-case")] +pub struct OvsDb { + #[serde(skip_serializing_if = "Option::is_none")] + pub external_ids: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub other_config: Option>, +} + +#[derive(Deserialize, Serialize, Clone, Debug, JsonSchema)] +#[serde(rename_all = "kebab-case")] +pub struct PatchState { + #[serde(skip_serializing_if = "Option::is_none")] + pub peer: Option, +} + +#[derive(Deserialize, Serialize, Clone, Debug, Default, JsonSchema)] +#[serde(rename_all = "kebab-case")] +pub struct Interface { pub name: String, #[serde(skip_serializing_if = "Option::is_none")] pub description: Option, - pub r#type: String, + pub r#type: InterfaceType, pub state: String, #[serde(skip_serializing_if = "Option::is_none")] pub mac_address: Option, @@ -99,9 +295,81 @@ pub struct InterfaceSpec { #[serde(skip_serializing_if = "Option::is_none")] pub linux_bridge: Option, #[serde(skip_serializing_if = "Option::is_none")] + #[serde(alias = "bridge")] pub ovs_bridge: Option, #[serde(skip_serializing_if = "Option::is_none")] - pub ethtool: Option, + pub ethtool: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub accept_all_mac_addresses: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub identifier: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub lldp: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub permanent_mac_address: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub max_mtu: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub min_mtu: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub mptcp: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub profile_name: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub wait_ip: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub ovs_db: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub driver: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub patch: Option, +} + +#[derive(Deserialize, Serialize, Clone, PartialEq, Eq, PartialOrd, Ord, Debug, JsonSchema)] +#[serde(rename_all = "kebab-case")] +pub enum InterfaceType { + #[serde(rename = "unknown")] + Unknown, + #[serde(rename = "dummy")] + Dummy, + #[serde(rename = "loopback")] + Loopback, + #[serde(rename = "linux-bridge")] + LinuxBridge, + #[serde(rename = "ovs-bridge")] + OvsBridge, + #[serde(rename = "ovs-interface")] + OvsInterface, + #[serde(rename = "bond")] + Bond, + #[serde(rename = "ipvlan")] + IpVlan, + #[serde(rename = "vlan")] + Vlan, + #[serde(rename = "vxlan")] + Vxlan, + #[serde(rename = "mac-vlan")] + Macvlan, + #[serde(rename = "mac-vtap")] + Macvtap, + #[serde(rename = "ethernet")] + Ethernet, + #[serde(rename = "infiniband")] + Infiniband, + #[serde(rename = "vrf")] + Vrf, + #[serde(rename = "veth")] + Veth, + #[serde(rename = "ipsec")] + Ipsec, + #[serde(rename = "hsr")] + Hrs, +} + +impl Default for InterfaceType { + fn default() -> Self { + Self::Loopback + } } #[derive(Deserialize, Serialize, Clone, Debug, Default, JsonSchema)] @@ -287,11 +555,15 @@ pub struct OvsBridgeSpec { #[serde(rename_all = "kebab-case")] pub struct OvsBridgeOptions { #[serde(skip_serializing_if = "Option::is_none")] - pub stp: Option, + pub stp: Option, #[serde(skip_serializing_if = "Option::is_none")] pub rstp: Option, #[serde(skip_serializing_if = "Option::is_none")] pub mcast_snooping_enable: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub datapath: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub fail_mode: Option, } #[derive(Deserialize, Serialize, Clone, Debug, Default, JsonSchema)] @@ -305,18 +577,3 @@ pub struct OvsPortSpec { #[serde(skip_serializing_if = "Option::is_none")] pub r#type: Option, } - -#[derive(Deserialize, Serialize, Clone, Debug, Default, JsonSchema)] -#[serde(rename_all = "kebab-case")] -pub struct EthtoolSpec { - // TODO: Properly describe this spec (https://nmstate.io/devel/yaml_api.html#ethtool) -} - -#[derive(Deserialize, Serialize, Clone, Debug, Default, JsonSchema)] -#[serde(rename_all = "kebab-case")] -pub struct EthtoolFecSpec { - #[serde(skip_serializing_if = "Option::is_none")] - pub auto: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub mode: Option, -}