configure bond with NMState

This commit is contained in:
Ian Letourneau 2025-09-25 16:31:40 -04:00
parent 0de52aedbf
commit ffe3c09907
13 changed files with 658 additions and 95 deletions

1
Cargo.lock generated
View File

@ -680,6 +680,7 @@ version = "0.1.0"
dependencies = [
"async-trait",
"harmony_types",
"log",
"russh",
"russh-keys",
"tokio",

View File

@ -7,7 +7,8 @@ license.workspace = true
[dependencies]
async-trait.workspace = true
harmony_types = { version = "0.1.0", path = "../harmony_types" }
harmony_types = { path = "../harmony_types" }
russh.workspace = true
russh-keys.workspace = true
tokio.workspace = true
log.workspace = true

View File

@ -5,6 +5,7 @@ use std::{
use async_trait::async_trait;
use harmony_types::net::{IpAddress, MacAddress};
use log::{debug, info};
use russh::client::{Handle, Handler};
use russh_keys::key;
use std::str::FromStr;
@ -21,7 +22,12 @@ pub struct BrocadeClient {
}
impl BrocadeClient {
pub async fn init(ip: IpAddress, username: &str, password: &str) -> Result<Self, Error> {
pub async fn init(
ip_addresses: &[IpAddress],
username: &str,
password: &str,
) -> Result<Self, Error> {
let ip = ip_addresses[0];
let config = russh::client::Config::default();
let mut client = russh::client::connect(Arc::new(config), (ip, 22), Client {}).await?;
@ -64,6 +70,8 @@ impl BrocadeClient {
}
pub async fn configure_port_channel(&self, ports: &[String]) -> Result<u8, Error> {
info!("[Brocade] Configuring port-channel with ports: {ports:?}");
let channel_id = self.find_available_channel_id().await?;
let mut commands = Vec::new();
@ -93,7 +101,8 @@ impl BrocadeClient {
}
pub async fn find_available_channel_id(&self) -> Result<u8, Error> {
// FIXME: The command might vary slightly by Brocade OS version.
debug!("[Brocade] Finding next available channel id...");
let output = self.run_command("show port-channel summary").await?;
let mut used_ids = Vec::new();
@ -121,10 +130,13 @@ impl BrocadeClient {
}
}
debug!("[Brocade] Found channel id '{next_id}'");
Ok(next_id)
}
async fn run_command(&self, command: &str) -> Result<String, Error> {
debug!("[Brocade] Running command: '{command}'...");
let mut channel = self.client.channel_open_session().await?;
let mut output = Vec::new();
@ -142,9 +154,9 @@ impl BrocadeClient {
}
russh::ChannelMsg::ExitStatus { exit_status } => {
if exit_status != 0 {
let output_str = String::from_utf8(output).unwrap_or_default();
return Err(Error::CommandError(format!(
"Command failed with exit status {exit_status}, output {}",
String::from_utf8(output).unwrap_or_default()
"Command failed with exit status {exit_status}, output {output_str}",
)));
}
}
@ -170,6 +182,8 @@ impl BrocadeClient {
// Execute commands sequentially and check for errors immediately.
for command in commands {
debug!("[Brocade] Running command: '{command}'...");
let mut output = Vec::new();
channel.exec(true, command.as_str()).await?;
@ -187,7 +201,7 @@ impl BrocadeClient {
if exit_status != 0 {
let output_str = String::from_utf8(output).unwrap_or_default();
return Err(Error::CommandError(format!(
"Command '{command}' failed with exit status {exit_status}: {output_str}",
"Command failed with exit status {exit_status}: {output_str}",
)));
}
}

View File

@ -77,7 +77,7 @@ harmony_secret = { path = "../harmony_secret" }
askama.workspace = true
sqlx.workspace = true
inquire.workspace = true
brocade = { version = "0.1.0", path = "../brocade" }
brocade = { path = "../brocade" }
[dev-dependencies]
pretty_assertions.workspace = true

View File

@ -5,16 +5,25 @@ use harmony_secret::Secret;
use harmony_secret::SecretManager;
use harmony_types::net::MacAddress;
use harmony_types::net::Url;
use k8s_openapi::api::core::v1::Namespace;
use kube::api::ObjectMeta;
use log::debug;
use log::info;
use russh::client;
use russh::client::Handler;
use serde::Deserialize;
use serde::Serialize;
use crate::data::FileContent;
use crate::executors::ExecutorError;
use crate::hardware::PhysicalHost;
use crate::modules::okd::crd::InstallPlanApproval;
use crate::modules::okd::crd::OperatorGroup;
use crate::modules::okd::crd::OperatorGroupSpec;
use crate::modules::okd::crd::Subscription;
use crate::modules::okd::crd::SubscriptionSpec;
use crate::modules::okd::crd::nmstate;
use crate::modules::okd::crd::nmstate::NMState;
use crate::modules::okd::crd::nmstate::NodeNetworkConfigurationPolicy;
use crate::modules::okd::crd::nmstate::NodeNetworkConfigurationPolicySpec;
use crate::topology::PxeOptions;
use super::DHCPStaticEntry;
@ -35,12 +44,12 @@ use super::PreparationOutcome;
use super::Router;
use super::Switch;
use super::SwitchError;
use super::SwitchPort;
use super::TftpServer;
use super::Topology;
use super::k8s::K8sClient;
use std::error::Error;
use std::collections::BTreeMap;
use std::net::IpAddr;
use std::sync::Arc;
#[derive(Debug, Clone)]
@ -102,37 +111,224 @@ impl HAClusterTopology {
.to_string()
}
fn find_master_switch(&self) -> Option<LogicalHost> {
self.switch.first().cloned() // FIXME: Should we be smarter to find the master switch?
async fn ensure_nmstate_operator_installed(&self) -> Result<(), String> {
// FIXME: Find a way to check nmstate is already available (get pod -n openshift-nmstate)
debug!("Installing NMState operator...");
let k8s_client = self.k8s_client().await?;
let nmstate_namespace = Namespace {
metadata: ObjectMeta {
name: Some("openshift-nmstate".to_string()),
finalizers: Some(vec!["kubernetes".to_string()]),
..Default::default()
},
..Default::default()
};
debug!("Creating NMState namespace: {nmstate_namespace:#?}");
k8s_client
.apply(&nmstate_namespace, None)
.await
.map_err(|e| e.to_string())?;
let nmstate_operator_group = OperatorGroup {
metadata: ObjectMeta {
name: Some("openshift-nmstate".to_string()),
namespace: Some("openshift-nmstate".to_string()),
..Default::default()
},
spec: OperatorGroupSpec {
target_namespaces: vec!["openshift-nmstate".to_string()],
},
};
debug!("Creating NMState operator group: {nmstate_operator_group:#?}");
k8s_client
.apply(&nmstate_operator_group, None)
.await
.map_err(|e| e.to_string())?;
let nmstate_subscription = Subscription {
metadata: ObjectMeta {
name: Some("kubernetes-nmstate-operator".to_string()),
namespace: Some("openshift-nmstate".to_string()),
..Default::default()
},
spec: SubscriptionSpec {
channel: Some("stable".to_string()),
install_plan_approval: Some(InstallPlanApproval::Automatic),
name: "kubernetes-nmstate-operator".to_string(),
source: "redhat-operators".to_string(),
source_namespace: "openshift-marketplace".to_string(),
},
};
debug!("Subscribing to NMState Operator: {nmstate_subscription:#?}");
k8s_client
.apply(&nmstate_subscription, None)
.await
.map_err(|e| e.to_string())?;
let nmstate = NMState {
metadata: ObjectMeta {
name: Some("nmstate".to_string()),
..Default::default()
},
..Default::default()
};
debug!("Creating NMState: {nmstate:#?}");
k8s_client
.apply(&nmstate, None)
.await
.map_err(|e| e.to_string())?;
Ok(())
}
async fn configure_bond(&self, config: &HostNetworkConfig) -> Result<(), SwitchError> {
fn get_next_bond_id(&self) -> u8 {
42 // FIXME: Find a better way to declare the bond id
}
async fn configure_bond(
&self,
host: &PhysicalHost,
config: &HostNetworkConfig,
) -> Result<(), SwitchError> {
self.ensure_nmstate_operator_installed()
.await
.map_err(|e| {
SwitchError::new(format!(
"Can't configure bond, NMState operator not available: {e}"
))
})?;
let bond_config = self.create_bond_configuration(host, config);
debug!("Configuring bond for host {host:?}: {bond_config:#?}");
self.k8s_client()
.await
.unwrap()
.apply(&bond_config, None)
.await
.unwrap();
todo!()
}
async fn configure_port_channel(&self, config: &HostNetworkConfig) -> Result<u8, SwitchError> {
fn create_bond_configuration(
&self,
host: &PhysicalHost,
config: &HostNetworkConfig,
) -> NodeNetworkConfigurationPolicy {
let host_name = host.id.clone();
let bond_id = self.get_next_bond_id();
let bond_name = format!("bond{bond_id}");
let mut bond_mtu: Option<u32> = None;
let mut bond_mac_address: Option<String> = None;
let mut bond_ports = Vec::new();
let mut interfaces: Vec<nmstate::InterfaceSpec> = Vec::new();
for switch_port in &config.switch_ports {
let interface_name = switch_port.interface.name.clone();
interfaces.push(nmstate::InterfaceSpec {
name: interface_name.clone(),
description: Some(format!("Member of bond {bond_name}")),
r#type: "ethernet".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 {
enabled: Some(false),
..Default::default()
}),
ipv6: Some(nmstate::IpStackSpec {
enabled: Some(false),
..Default::default()
}),
link_aggregation: None,
..Default::default()
});
bond_ports.push(interface_name);
// Use the first port's details for the bond mtu and mac address
if bond_mtu.is_none() {
bond_mtu = Some(switch_port.interface.mtu);
}
if bond_mac_address.is_none() {
bond_mac_address = Some(switch_port.interface.mac_address.to_string());
}
}
interfaces.push(nmstate::InterfaceSpec {
name: bond_name.clone(),
description: Some(format!("Network bond for host {host_name}")),
r#type: "bond".to_string(),
state: "up".to_string(),
mtu: bond_mtu,
mac_address: bond_mac_address,
ipv4: Some(nmstate::IpStackSpec {
dhcp: Some(true),
enabled: Some(true),
..Default::default()
}),
ipv6: Some(nmstate::IpStackSpec {
dhcp: Some(true),
autoconf: Some(true),
enabled: Some(true),
..Default::default()
}),
link_aggregation: Some(nmstate::BondSpec {
mode: "802.3ad".to_string(),
ports: bond_ports,
..Default::default()
}),
..Default::default()
});
NodeNetworkConfigurationPolicy {
metadata: ObjectMeta {
name: Some(format!("{host_name}-bond-config")),
..Default::default()
},
spec: NodeNetworkConfigurationPolicySpec {
node_selector: Some(BTreeMap::from([(
"kubernetes.io/hostname".to_string(),
host_name.to_string(),
)])),
desired_state: nmstate::DesiredStateSpec { interfaces },
},
}
}
async fn get_switch_client(&self) -> Result<Box<dyn SwitchClient>, SwitchError> {
let auth = SecretManager::get_or_prompt::<BrocadeSwitchAuth>()
.await
.map_err(|e| SwitchError::new(format!("Failed to get credentials: {e}")))?;
let switch = self
.find_master_switch()
.ok_or(SwitchError::new("No switch found in topology".to_string()))?;
let client = BrocadeSwitchClient::init(switch.ip, &auth.username, &auth.password)
// FIXME: We assume Brocade switches
let switches: Vec<IpAddr> = self.switch.iter().map(|s| s.ip).collect();
let client = BrocadeSwitchClient::init(&switches, &auth.username, &auth.password)
.await
.map_err(|e| SwitchError::new(format!("Failed to connect to switch: {e}")))?;
Ok(Box::new(client))
}
async fn configure_port_channel(&self, config: &HostNetworkConfig) -> Result<(), SwitchError> {
debug!("Configuring port channel: {config:#?}");
let client = self.get_switch_client().await?;
let switch_ports: Vec<String> = config
.switch_ports
.iter()
.map(|s| s.port_name.clone())
.collect();
let channel_id = client
client
.configure_port_channel(switch_ports)
.await
.map_err(|e| SwitchError::new(format!("Failed to configure switch: {e}")))?;
Ok(channel_id)
Ok(())
}
pub fn autoload() -> Self {
@ -312,12 +508,7 @@ impl HttpServer for HAClusterTopology {
#[async_trait]
impl Switch for HAClusterTopology {
async fn get_port_for_mac_address(&self, mac_address: &MacAddress) -> Option<String> {
let auth = SecretManager::get_or_prompt::<BrocadeSwitchAuth>()
.await
.unwrap();
let switch = self.find_master_switch()?;
let client = BrocadeSwitchClient::init(switch.ip, &auth.username, &auth.password).await;
let client = self.get_switch_client().await;
let Ok(client) = client else {
return None;
@ -328,18 +519,16 @@ impl Switch for HAClusterTopology {
async fn configure_host_network(
&self,
_host: &PhysicalHost,
host: &PhysicalHost,
config: HostNetworkConfig,
) -> Result<(), SwitchError> {
let _ = self.configure_bond(&config).await;
let channel_id = self.configure_port_channel(&config).await;
todo!()
self.configure_bond(host, &config).await?;
self.configure_port_channel(&config).await
}
}
#[async_trait]
trait SwitchClient {
trait SwitchClient: Send + Sync {
async fn find_port(&self, mac_address: &MacAddress) -> Option<String>;
async fn configure_port_channel(&self, switch_ports: Vec<String>) -> Result<u8, SwitchError>;
}
@ -349,8 +538,12 @@ struct BrocadeSwitchClient {
}
impl BrocadeSwitchClient {
async fn init(ip: IpAddress, username: &str, password: &str) -> Result<Self, brocade::Error> {
let brocade = BrocadeClient::init(ip, username, password).await?;
async fn init(
ip_addresses: &[IpAddress],
username: &str,
password: &str,
) -> Result<Self, brocade::Error> {
let brocade = BrocadeClient::init(ip_addresses, username, password).await?;
Ok(Self { brocade })
}
}
@ -451,8 +644,8 @@ impl DhcpServer for DummyInfra {
}
async fn set_dhcp_range(
&self,
start: &IpAddress,
end: &IpAddress,
_start: &IpAddress,
_end: &IpAddress,
) -> Result<(), ExecutorError> {
unimplemented!("{}", UNIMPLEMENTED_DUMMY_INFRA)
}

View File

@ -191,9 +191,16 @@ pub struct HostNetworkConfig {
#[derive(Clone, Debug, PartialEq)]
pub struct SwitchPort {
pub mac_address: MacAddress,
pub interface: NetworkInterface,
pub port_name: String,
// FIXME: Should we add speed as well? And other params
}
#[derive(Clone, Debug, PartialEq)]
pub struct NetworkInterface {
pub name: String,
pub mac_address: MacAddress,
pub speed_mbps: Option<u32>,
pub mtu: u32,
}
#[derive(Debug, Clone, new)]

View File

@ -11,7 +11,7 @@ use crate::{
okd::{host_network::HostNetworkConfigurationScore, templates::BootstrapIpxeTpl},
},
score::Score,
topology::{self, HAClusterTopology, HostBinding},
topology::{HAClusterTopology, HostBinding},
};
use async_trait::async_trait;
use derive_new::new;
@ -30,7 +30,7 @@ pub struct OKDSetup03ControlPlaneScore {}
impl Score<HAClusterTopology> for OKDSetup03ControlPlaneScore {
fn create_interpret(&self) -> Box<dyn Interpret<HAClusterTopology>> {
Box::new(OKDSetup03ControlPlaneInterpret::new(self.clone()))
Box::new(OKDSetup03ControlPlaneInterpret::new())
}
fn name(&self) -> String {
@ -40,17 +40,15 @@ impl Score<HAClusterTopology> for OKDSetup03ControlPlaneScore {
#[derive(Debug, Clone)]
pub struct OKDSetup03ControlPlaneInterpret {
score: OKDSetup03ControlPlaneScore,
version: Version,
status: InterpretStatus,
}
impl OKDSetup03ControlPlaneInterpret {
pub fn new(score: OKDSetup03ControlPlaneScore) -> Self {
pub fn new() -> Self {
let version = Version::from("1.0.0").unwrap();
Self {
version,
score,
status: InterpretStatus::QUEUED,
}
}
@ -161,7 +159,7 @@ impl OKDSetup03ControlPlaneInterpret {
}
.to_string();
debug!("[ControlPlane] iPXE content template:\n{}", content);
debug!("[ControlPlane] iPXE content template:\n{content}");
// Create and apply an iPXE boot file for each node.
for node in nodes {
@ -191,16 +189,13 @@ impl OKDSetup03ControlPlaneInterpret {
/// Prompts the user to reboot the target control plane nodes.
async fn reboot_targets(&self, nodes: &Vec<PhysicalHost>) -> Result<(), InterpretError> {
let node_ids: Vec<String> = nodes.iter().map(|n| n.id.to_string()).collect();
info!(
"[ControlPlane] Requesting reboot for control plane nodes: {:?}",
node_ids
);
info!("[ControlPlane] Requesting reboot for control plane nodes: {node_ids:?}",);
let confirmation = inquire::Confirm::new(
&format!("Please reboot the {} control plane nodes ({}) to apply their PXE configuration. Press enter when ready.", nodes.len(), node_ids.join(", ")),
)
.prompt()
.map_err(|e| InterpretError::new(format!("User prompt failed: {}", e)))?;
.map_err(|e| InterpretError::new(format!("User prompt failed: {e}")))?;
if !confirmation {
return Err(InterpretError::new(
@ -222,19 +217,18 @@ impl OKDSetup03ControlPlaneInterpret {
topology: &HAClusterTopology,
hosts: &Vec<PhysicalHost>,
) -> Result<(), InterpretError> {
// Generate MC or NNCP from inventory NIC data; apply via ignition or post-join.
info!("[ControlPlane] Ensuring persistent bonding via MachineConfig/NNCP");
let score = HostNetworkConfigurationScore {
hosts: hosts.clone(), // FIXME: Avoid clone if possible
};
score.interpret(inventory, topology).await?;
score.interpret(inventory, topology);
// Generate MC or NNCP from inventory NIC data; apply via ignition or post-join.
info!("[ControlPlane] Ensuring persistent bonding via MachineConfig/NNCP");
inquire::Confirm::new(
"Network configuration for control plane nodes is not automated yet. Configure it manually if needed.",
)
.prompt()
.map_err(|e| InterpretError::new(format!("User prompt failed: {}", e)))?;
.map_err(|e| InterpretError::new(format!("User prompt failed: {e}")))?;
Ok(())
}

View File

@ -0,0 +1,43 @@
use kube::CustomResource;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
#[derive(CustomResource, Deserialize, Serialize, Clone, Debug, JsonSchema)]
#[kube(
group = "machineconfiguration.openshift.io",
version = "v1",
kind = "MachineConfig",
namespaced
)]
#[serde(rename_all = "camelCase")]
pub struct MachineConfigSpec {
pub config: Config,
}
#[derive(Deserialize, Serialize, Clone, Debug, JsonSchema)]
#[serde(rename_all = "camelCase")]
pub struct Config {
pub ignition: Ignition,
pub storage: Option<Storage>,
}
#[derive(Deserialize, Serialize, Clone, Debug, JsonSchema)]
pub struct Ignition {
pub version: String,
}
#[derive(Deserialize, Serialize, Clone, Debug, JsonSchema)]
pub struct Storage {
pub files: Vec<File>,
}
#[derive(Deserialize, Serialize, Clone, Debug, JsonSchema)]
pub struct File {
pub path: String,
pub contents: FileContents,
}
#[derive(Deserialize, Serialize, Clone, Debug, JsonSchema)]
pub struct FileContents {
pub source: String,
}

View File

@ -0,0 +1,42 @@
use kube::CustomResource;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
pub mod machineconfig;
pub mod nmstate;
#[derive(CustomResource, Deserialize, Serialize, Clone, Debug, JsonSchema)]
#[kube(
group = "operators.coreos.com",
version = "v1",
kind = "OperatorGroup",
namespaced
)]
#[serde(rename_all = "camelCase")]
pub struct OperatorGroupSpec {
pub target_namespaces: Vec<String>,
}
#[derive(CustomResource, Deserialize, Serialize, Clone, Debug, JsonSchema)]
#[kube(
group = "operators.coreos.com",
version = "v1alpha1",
kind = "Subscription",
namespaced
)]
#[serde(rename_all = "camelCase")]
pub struct SubscriptionSpec {
pub name: String,
pub source: String,
pub source_namespace: String,
pub channel: Option<String>,
pub install_plan_approval: Option<InstallPlanApproval>,
}
#[derive(Deserialize, Serialize, Clone, Debug, JsonSchema)]
pub enum InstallPlanApproval {
#[serde(rename = "Automatic")]
Automatic,
#[serde(rename = "Manual")]
Manual,
}

View File

@ -0,0 +1,251 @@
use std::collections::BTreeMap;
use kube::CustomResource;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use serde_json::Value;
#[derive(CustomResource, Deserialize, Serialize, Clone, Debug, JsonSchema)]
#[kube(group = "nmstate.io", version = "v1", kind = "NMState", namespaced)]
#[serde(rename_all = "camelCase")]
pub struct NMStateSpec {
pub probe_configuration: Option<ProbeConfig>,
}
impl Default for NMState {
fn default() -> Self {
Self {
metadata: Default::default(),
spec: NMStateSpec {
probe_configuration: None,
},
}
}
}
#[derive(Deserialize, Serialize, Clone, Debug, JsonSchema)]
#[serde(rename_all = "camelCase")]
pub struct ProbeConfig {
pub dns: ProbeDns,
}
#[derive(Deserialize, Serialize, Clone, Debug, JsonSchema)]
#[serde(rename_all = "camelCase")]
pub struct ProbeDns {
pub host: String,
}
#[derive(CustomResource, Deserialize, Serialize, Clone, Debug, JsonSchema)]
#[kube(
group = "nmstate.io",
version = "v1",
kind = "NodeNetworkConfigurationPolicy",
namespaced
)]
#[serde(rename_all = "camelCase")]
pub struct NodeNetworkConfigurationPolicySpec {
pub node_selector: Option<BTreeMap<String, String>>,
pub desired_state: DesiredStateSpec,
}
#[derive(Deserialize, Serialize, Clone, Debug, JsonSchema)]
#[serde(rename_all = "kebab-case")]
pub struct DesiredStateSpec {
pub interfaces: Vec<InterfaceSpec>,
}
#[derive(Deserialize, Serialize, Clone, Debug, Default, JsonSchema)]
#[serde(rename_all = "kebab-case")]
pub struct InterfaceSpec {
pub name: String,
pub description: Option<String>,
pub r#type: String,
pub state: String,
pub mac_address: Option<String>,
pub mtu: Option<u32>,
pub controller: Option<String>,
pub ipv4: Option<IpStackSpec>,
pub ipv6: Option<IpStackSpec>,
pub ethernet: Option<EthernetSpec>,
pub link_aggregation: Option<BondSpec>,
pub vlan: Option<VlanSpec>,
pub vxlan: Option<VxlanSpec>,
pub mac_vtap: Option<MacVtapSpec>,
pub mac_vlan: Option<MacVlanSpec>,
pub infiniband: Option<InfinibandSpec>,
pub linux_bridge: Option<LinuxBridgeSpec>,
pub ovs_bridge: Option<OvsBridgeSpec>,
pub ethtool: Option<EthtoolSpec>,
}
#[derive(Deserialize, Serialize, Clone, Debug, Default, JsonSchema)]
#[serde(rename_all = "kebab-case")]
pub struct IpStackSpec {
pub enabled: Option<bool>,
pub dhcp: Option<bool>,
pub autoconf: Option<bool>,
pub address: Option<Vec<IpAddressSpec>>,
pub auto_dns: Option<bool>,
pub auto_gateway: Option<bool>,
pub auto_routes: Option<bool>,
pub dhcp_client_id: Option<String>,
pub dhcp_duid: Option<String>,
}
#[derive(Deserialize, Serialize, Clone, Debug, Default, JsonSchema)]
#[serde(rename_all = "kebab-case")]
pub struct IpAddressSpec {
pub ip: String,
pub prefix_length: u8,
}
#[derive(Deserialize, Serialize, Clone, Debug, Default, JsonSchema)]
#[serde(rename_all = "kebab-case")]
pub struct EthernetSpec {
pub speed: Option<u32>,
pub duplex: Option<String>,
pub auto_negotiation: Option<bool>,
}
#[derive(Deserialize, Serialize, Clone, Debug, Default, JsonSchema)]
#[serde(rename_all = "kebab-case")]
pub struct BondSpec {
pub mode: String,
pub ports: Vec<String>,
pub options: Option<BTreeMap<String, Value>>,
}
#[derive(Deserialize, Serialize, Clone, Debug, Default, JsonSchema)]
#[serde(rename_all = "kebab-case")]
pub struct VlanSpec {
pub base_iface: String,
pub id: u16,
pub protocol: Option<String>,
}
#[derive(Deserialize, Serialize, Clone, Debug, Default, JsonSchema)]
#[serde(rename_all = "kebab-case")]
pub struct VxlanSpec {
pub base_iface: String,
pub id: u32,
pub remote: String,
pub local: Option<String>,
pub learning: Option<bool>,
pub destination_port: Option<u16>,
}
#[derive(Deserialize, Serialize, Clone, Debug, Default, JsonSchema)]
#[serde(rename_all = "kebab-case")]
pub struct MacVtapSpec {
pub base_iface: String,
pub mode: String,
pub promiscuous: Option<bool>,
}
#[derive(Deserialize, Serialize, Clone, Debug, Default, JsonSchema)]
#[serde(rename_all = "kebab-case")]
pub struct MacVlanSpec {
pub base_iface: String,
pub mode: String,
pub promiscuous: Option<bool>,
}
#[derive(Deserialize, Serialize, Clone, Debug, Default, JsonSchema)]
#[serde(rename_all = "kebab-case")]
pub struct InfinibandSpec {
pub base_iface: String,
pub pkey: String,
pub mode: String,
}
#[derive(Deserialize, Serialize, Clone, Debug, Default, JsonSchema)]
#[serde(rename_all = "kebab-case")]
pub struct LinuxBridgeSpec {
pub options: Option<LinuxBridgeOptions>,
pub ports: Option<Vec<LinuxBridgePort>>,
}
#[derive(Deserialize, Serialize, Clone, Debug, Default, JsonSchema)]
#[serde(rename_all = "kebab-case")]
pub struct LinuxBridgeOptions {
pub mac_ageing_time: Option<u32>,
pub multicast_snooping: Option<bool>,
pub stp: Option<StpOptions>,
}
#[derive(Deserialize, Serialize, Clone, Debug, Default, JsonSchema)]
#[serde(rename_all = "kebab-case")]
pub struct StpOptions {
pub enabled: Option<bool>,
pub forward_delay: Option<u16>,
pub hello_time: Option<u16>,
pub max_age: Option<u16>,
pub priority: Option<u16>,
}
#[derive(Deserialize, Serialize, Clone, Debug, Default, JsonSchema)]
#[serde(rename_all = "kebab-case")]
pub struct LinuxBridgePort {
pub name: String,
pub vlan: Option<LinuxBridgePortVlan>,
}
#[derive(Deserialize, Serialize, Clone, Debug, Default, JsonSchema)]
#[serde(rename_all = "kebab-case")]
pub struct LinuxBridgePortVlan {
pub mode: Option<String>,
pub trunk_tags: Option<Vec<VlanTag>>,
pub tag: Option<u16>,
pub enable_native: Option<bool>,
}
#[derive(Deserialize, Serialize, Clone, Debug, Default, JsonSchema)]
#[serde(rename_all = "kebab-case")]
pub struct VlanTag {
pub id: u16,
pub id_range: Option<VlanIdRange>,
}
#[derive(Deserialize, Serialize, Clone, Debug, Default, JsonSchema)]
#[serde(rename_all = "kebab-case")]
pub struct VlanIdRange {
pub min: u16,
pub max: u16,
}
#[derive(Deserialize, Serialize, Clone, Debug, Default, JsonSchema)]
#[serde(rename_all = "kebab-case")]
pub struct OvsBridgeSpec {
pub options: Option<OvsBridgeOptions>,
pub ports: Option<Vec<OvsPortSpec>>,
}
#[derive(Deserialize, Serialize, Clone, Debug, Default, JsonSchema)]
#[serde(rename_all = "kebab-case")]
pub struct OvsBridgeOptions {
pub stp: Option<bool>,
pub rstp: Option<bool>,
pub mcast_snooping_enable: Option<bool>,
}
#[derive(Deserialize, Serialize, Clone, Debug, Default, JsonSchema)]
#[serde(rename_all = "kebab-case")]
pub struct OvsPortSpec {
pub name: String,
pub link_aggregation: Option<BondSpec>,
pub vlan: Option<LinuxBridgePortVlan>,
pub r#type: Option<String>,
}
#[derive(Deserialize, Serialize, Clone, Debug, Default, JsonSchema)]
#[serde(rename_all = "kebab-case")]
pub struct EthtoolSpec {
// FIXME: Properly describe this spec
}
#[derive(Deserialize, Serialize, Clone, Debug, Default, JsonSchema)]
#[serde(rename_all = "kebab-case")]
pub struct EthtoolFecSpec {
pub auto: Option<bool>,
pub mode: Option<String>,
}

View File

@ -1,5 +1,5 @@
use async_trait::async_trait;
use harmony_types::{id::Id, net::MacAddress};
use harmony_types::id::Id;
use serde::Serialize;
use crate::{
@ -8,7 +8,7 @@ use crate::{
interpret::{Interpret, InterpretError, InterpretName, InterpretStatus, Outcome},
inventory::Inventory,
score::Score,
topology::{HostNetworkConfig, Switch, SwitchPort, Topology},
topology::{HostNetworkConfig, NetworkInterface, Switch, SwitchPort, Topology},
};
#[derive(Debug, Clone, Serialize)]
@ -59,10 +59,17 @@ impl<T: Topology + Switch> Interpret<T> for HostNetworkConfigurationInterpret {
for host in &self.score.hosts {
let mut switch_ports = vec![];
for mac_address in host.get_mac_address() {
for network_interface in &host.network {
let mac_address = network_interface.mac_address;
if let Some(port_name) = topology.get_port_for_mac_address(&mac_address).await {
switch_ports.push(SwitchPort {
mac_address,
interface: NetworkInterface {
name: network_interface.name.clone(),
mac_address,
speed_mbps: network_interface.speed_mbps,
mtu: network_interface.mtu,
},
port_name,
});
}
@ -73,14 +80,6 @@ impl<T: Topology + Switch> Interpret<T> for HostNetworkConfigurationInterpret {
.await;
}
// foreach hosts
// foreach mac addresses
// let port = topology.get_port_for_mac_address(); // si pas de port -> mac address pas connectee
// create port channel for all ports found
// create bond for all valid addresses (port found)
// apply network config
// topology.configure_host_network(host, config) <--- will create both bonds & port channels
Ok(Outcome::success("".into()))
}
}
@ -88,7 +87,7 @@ impl<T: Topology + Switch> Interpret<T> for HostNetworkConfigurationInterpret {
#[cfg(test)]
mod tests {
use assertor::*;
use harmony_inventory_agent::hwinfo::NetworkInterface;
use harmony_types::net::MacAddress;
use lazy_static::lazy_static;
use crate::{
@ -107,19 +106,31 @@ mod tests {
lazy_static! {
pub static ref HOST_ID: Id = Id::from_str("host-1").unwrap();
pub static ref ANOTHER_HOST_ID: Id = Id::from_str("host-2").unwrap();
pub static ref EXISTING_INTERFACE: MacAddress =
MacAddress::try_from("AA:BB:CC:DD:EE:F1".to_string()).unwrap();
pub static ref ANOTHER_EXISTING_INTERFACE: MacAddress =
MacAddress::try_from("AA:BB:CC:DD:EE:F2".to_string()).unwrap();
pub static ref UNKNOWN_INTERFACE: MacAddress =
MacAddress::try_from("11:22:33:44:55:61".to_string()).unwrap();
pub static ref EXISTING_INTERFACE: NetworkInterface = NetworkInterface {
mac_address: MacAddress::try_from("AA:BB:CC:DD:EE:F1".to_string()).unwrap(),
name: "interface-1".into(),
speed_mbps: None,
mtu: 1,
};
pub static ref ANOTHER_EXISTING_INTERFACE: NetworkInterface = NetworkInterface {
mac_address: MacAddress::try_from("AA:BB:CC:DD:EE:F2".to_string()).unwrap(),
name: "interface-2".into(),
speed_mbps: None,
mtu: 1,
};
pub static ref UNKNOWN_INTERFACE: NetworkInterface = NetworkInterface {
mac_address: MacAddress::try_from("11:22:33:44:55:61".to_string()).unwrap(),
name: "unknown-interface".into(),
speed_mbps: None,
mtu: 1,
};
pub static ref PORT: String = "1/0/42".into();
pub static ref ANOTHER_PORT: String = "2/0/42".into();
}
#[tokio::test]
async fn host_with_one_mac_address_should_create_bond_with_one_interface() {
let host = given_host(&HOST_ID, vec![*EXISTING_INTERFACE]);
let host = given_host(&HOST_ID, vec![EXISTING_INTERFACE.clone()]);
let score = given_score(vec![host]);
let topology = TopologyWithSwitch::new();
@ -130,7 +141,7 @@ mod tests {
HOST_ID.clone(),
HostNetworkConfig {
switch_ports: vec![SwitchPort {
mac_address: *EXISTING_INTERFACE,
interface: EXISTING_INTERFACE.clone(),
port_name: PORT.clone(),
}],
},
@ -141,7 +152,10 @@ mod tests {
async fn host_with_multiple_mac_addresses_should_create_one_bond_with_all_interfaces() {
let score = given_score(vec![given_host(
&HOST_ID,
vec![*EXISTING_INTERFACE, *ANOTHER_EXISTING_INTERFACE],
vec![
EXISTING_INTERFACE.clone(),
ANOTHER_EXISTING_INTERFACE.clone(),
],
)]);
let topology = TopologyWithSwitch::new();
@ -153,11 +167,11 @@ mod tests {
HostNetworkConfig {
switch_ports: vec![
SwitchPort {
mac_address: *EXISTING_INTERFACE,
interface: EXISTING_INTERFACE.clone(),
port_name: PORT.clone(),
},
SwitchPort {
mac_address: *ANOTHER_EXISTING_INTERFACE,
interface: ANOTHER_EXISTING_INTERFACE.clone(),
port_name: ANOTHER_PORT.clone(),
},
],
@ -168,8 +182,8 @@ mod tests {
#[tokio::test]
async fn multiple_hosts_should_create_one_bond_per_host() {
let score = given_score(vec![
given_host(&HOST_ID, vec![*EXISTING_INTERFACE]),
given_host(&ANOTHER_HOST_ID, vec![*ANOTHER_EXISTING_INTERFACE]),
given_host(&HOST_ID, vec![EXISTING_INTERFACE.clone()]),
given_host(&ANOTHER_HOST_ID, vec![ANOTHER_EXISTING_INTERFACE.clone()]),
]);
let topology = TopologyWithSwitch::new();
@ -181,7 +195,7 @@ mod tests {
HOST_ID.clone(),
HostNetworkConfig {
switch_ports: vec![SwitchPort {
mac_address: *EXISTING_INTERFACE,
interface: EXISTING_INTERFACE.clone(),
port_name: PORT.clone(),
}],
},
@ -190,7 +204,7 @@ mod tests {
ANOTHER_HOST_ID.clone(),
HostNetworkConfig {
switch_ports: vec![SwitchPort {
mac_address: *ANOTHER_EXISTING_INTERFACE,
interface: ANOTHER_EXISTING_INTERFACE.clone(),
port_name: ANOTHER_PORT.clone(),
}],
},
@ -201,7 +215,7 @@ mod tests {
#[tokio::test]
async fn port_not_found_for_mac_address_should_not_configure_interface() {
// FIXME: Should it still configure an empty bond/port channel?
let score = given_score(vec![given_host(&HOST_ID, vec![*UNKNOWN_INTERFACE])]);
let score = given_score(vec![given_host(&HOST_ID, vec![UNKNOWN_INTERFACE.clone()])]);
let topology = TopologyWithSwitch::new_port_not_found();
let _ = score.interpret(&Inventory::empty(), &topology).await;
@ -219,8 +233,8 @@ mod tests {
HostNetworkConfigurationScore { hosts }
}
fn given_host(id: &Id, mac_addresses: Vec<MacAddress>) -> PhysicalHost {
let network = mac_addresses.iter().map(|m| given_interface(*m)).collect();
fn given_host(id: &Id, network_interfaces: Vec<NetworkInterface>) -> PhysicalHost {
let network = network_interfaces.iter().map(given_interface).collect();
PhysicalHost {
id: id.clone(),
@ -233,13 +247,15 @@ mod tests {
}
}
fn given_interface(mac_address: MacAddress) -> NetworkInterface {
NetworkInterface {
name: format!("{mac_address}"),
mac_address,
speed_mbps: None,
fn given_interface(
interface: &NetworkInterface,
) -> harmony_inventory_agent::hwinfo::NetworkInterface {
harmony_inventory_agent::hwinfo::NetworkInterface {
name: interface.name.clone(),
mac_address: interface.mac_address,
speed_mbps: interface.speed_mbps,
is_up: true,
mtu: 1,
mtu: interface.mtu,
ipv4_addresses: vec![],
ipv6_addresses: vec![],
driver: "driver".into(),

View File

@ -19,4 +19,5 @@ pub use bootstrap_03_control_plane::*;
pub use bootstrap_04_workers::*;
pub use bootstrap_05_sanity_check::*;
pub use bootstrap_06_installation_report::*;
pub mod crd;
pub mod host_network;

View File

@ -41,7 +41,7 @@ impl TryFrom<String> for MacAddress {
bytes[i] = u8::from_str_radix(part, 16).map_err(|_| {
std::io::Error::new(
std::io::ErrorKind::InvalidInput,
format!("Invalid hex value in part {}: '{}'", i, part),
format!("Invalid hex value in part {i}: '{part}'"),
)
})?;
}
@ -106,8 +106,8 @@ impl Serialize for Url {
impl std::fmt::Display for Url {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Url::LocalFolder(path) => write!(f, "{}", path),
Url::Url(url) => write!(f, "{}", url),
Url::LocalFolder(path) => write!(f, "{path}"),
Url::Url(url) => write!(f, "{url}"),
}
}
}