From ad61be277b1c2e69482631ffab7ee588b6a1a828 Mon Sep 17 00:00:00 2001 From: Ian Letourneau Date: Tue, 7 Oct 2025 21:27:45 -0400 Subject: [PATCH] refactor brocade to support different shell versions (e.g. FastIron vs NOS) --- Cargo.lock | 34 +- brocade/examples/main.rs | 17 +- brocade/src/fast_iron.rs | 161 +++++ brocade/src/lib.rs | 691 ++++------------------ brocade/src/shell.rs | 330 +++++++++++ brocade/src/ssh.rs | 113 ++++ harmony/src/domain/topology/ha_cluster.rs | 9 +- harmony/src/domain/topology/network.rs | 16 +- harmony/src/infra/brocade.rs | 33 +- harmony/src/modules/okd/host_network.rs | 24 +- harmony_types/src/id.rs | 2 +- harmony_types/src/lib.rs | 1 + harmony_types/src/switch.rs | 176 ++++++ 13 files changed, 973 insertions(+), 634 deletions(-) create mode 100644 brocade/src/fast_iron.rs create mode 100644 brocade/src/shell.rs create mode 100644 brocade/src/ssh.rs create mode 100644 harmony_types/src/switch.rs diff --git a/Cargo.lock b/Cargo.lock index 7e74492..c3a4e75 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -679,8 +679,10 @@ name = "brocade" version = "0.1.0" dependencies = [ "async-trait", + "env_logger", "harmony_types", "log", + "regex", "russh", "russh-keys", "tokio", @@ -2427,6 +2429,17 @@ dependencies = [ "tokio", ] +[[package]] +name = "harmony_derive" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d138bbb32bb346299c5f95fbb53532313f39927cb47c411c99c634ef8665ef7" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + [[package]] name = "harmony_inventory_agent" version = "0.1.0" @@ -3873,6 +3886,19 @@ dependencies = [ "web-time", ] +[[package]] +name = "okd_host_network" +version = "0.1.0" +dependencies = [ + "harmony", + "harmony_cli", + "harmony_derive", + "harmony_inventory_agent", + "harmony_macros", + "harmony_types", + "tokio", +] + [[package]] name = "once_cell" version = "1.21.3" @@ -4560,9 +4586,9 @@ dependencies = [ [[package]] name = "regex" -version = "1.11.2" +version = "1.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23d7fd106d8c02486a8d64e778353d1cffe08ce79ac2e82f540c86d0facf6912" +checksum = "8b5288124840bee7b386bc413c487869b360b2b4ec421ea56425128692f2a82c" dependencies = [ "aho-corasick 1.1.3", "memchr", @@ -4572,9 +4598,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.10" +version = "0.4.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b9458fa0bfeeac22b5ca447c63aaf45f28439a709ccd244698632f9aa6394d6" +checksum = "833eb9ce86d40ef33cb1306d8accf7bc8ec2bfea4355cbdebb3df68b40925cad" dependencies = [ "aho-corasick 1.1.3", "memchr", diff --git a/brocade/examples/main.rs b/brocade/examples/main.rs index d69060b..756240f 100644 --- a/brocade/examples/main.rs +++ b/brocade/examples/main.rs @@ -1,24 +1,29 @@ use std::net::{IpAddr, Ipv4Addr}; -use brocade::BrocadeClient; +use harmony_types::switch::PortLocation; #[tokio::main] async fn main() { env_logger::init(); - let ip = IpAddr::V4(Ipv4Addr::new(10, 0, 0, 250)); + let ip = IpAddr::V4(Ipv4Addr::new(10, 0, 0, 250)); // old brocade @ ianlet + // let ip = IpAddr::V4(Ipv4Addr::new(192, 168, 55, 101)); // brocade @ sto1 let switch_addresses = vec![ip]; - let brocade = BrocadeClient::init(&switch_addresses, "admin", "password", None) + let brocade = brocade::init(&switch_addresses, 22, "admin", "password", None) .await .expect("Brocade client failed to connect"); + let version = brocade.version().await.unwrap(); + println!("Version: {version:?}"); + + println!("--------------"); println!("Showing MAC Address table..."); let mac_adddresses = brocade.show_mac_address_table().await.unwrap(); println!("VLAN\tMAC\t\t\tPORT"); for mac in mac_adddresses { - println!("{}\t{}\t{}", mac.vlan, mac.mac_address, mac.port_name); + println!("{}\t{}\t{}", mac.vlan, mac.mac_address, mac.port); } println!("--------------"); @@ -37,11 +42,11 @@ async fn main() { println!("--------------"); let channel_name = "HARMONY_LAG"; - let ports = vec!["1/1/3".to_string()]; + let ports = [PortLocation(1, 1, 3), PortLocation(1, 1, 4)]; println!("Creating port channel '{channel_name}' with ports {ports:?}'..."); brocade - .create_port_channel(channel_name, channel_id, &ports) + .create_port_channel(channel_id, channel_name, &ports) .await .unwrap(); diff --git a/brocade/src/fast_iron.rs b/brocade/src/fast_iron.rs new file mode 100644 index 0000000..95db621 --- /dev/null +++ b/brocade/src/fast_iron.rs @@ -0,0 +1,161 @@ +use super::BrocadeClient; +use crate::{ + BrocadeInfo, Error, ExecutionMode, MacAddressEntry, PortChannelId, parse_brocade_mac_address, + shell::BrocadeShell, +}; + +use async_trait::async_trait; +use harmony_types::switch::{PortDeclaration, PortLocation}; +use log::{debug, info}; +use regex::Regex; +use std::{collections::HashSet, str::FromStr}; + +pub struct FastIronClient { + pub shell: BrocadeShell, + pub version: BrocadeInfo, +} + +impl FastIronClient { + pub fn parse_mac_entry(&self, line: &str) -> Option> { + debug!("[Brocade] Parsing mac address entry: {line}"); + let parts: Vec<&str> = line.split_whitespace().collect(); + if parts.len() < 3 { + return None; + } + + let (vlan, mac_address, port) = match parts.len() { + 3 => ( + u16::from_str(parts[0]).ok()?, + parse_brocade_mac_address(parts[1]).ok()?, + parts[2].to_string(), + ), + _ => ( + 1, + parse_brocade_mac_address(parts[0]).ok()?, + parts[1].to_string(), + ), + }; + + let port = + PortDeclaration::parse(&port).map_err(|e| Error::UnexpectedError(format!("{e}"))); + + match port { + Ok(p) => Some(Ok(MacAddressEntry { + vlan, + mac_address, + port: p, + })), + Err(e) => Some(Err(e)), + } + } + + pub fn build_port_channel_commands( + &self, + channel_name: &str, + channel_id: u8, + ports: &[PortLocation], + ) -> Vec { + let mut commands = vec![ + "configure terminal".to_string(), + format!("lag {channel_name} static id {channel_id}"), + ]; + + for port in ports { + commands.push(format!("ports ethernet {port}")); + } + + commands.push(format!("primary-port {}", ports[0])); + commands.push("deploy".into()); + commands.push("exit".into()); + commands.push("write memory".into()); + commands.push("exit".into()); + + commands + } +} + +#[async_trait] +impl BrocadeClient for FastIronClient { + async fn version(&self) -> Result { + Ok(self.version.clone()) + } + + async fn show_mac_address_table(&self) -> Result, Error> { + info!("[Brocade] Showing MAC address table..."); + + let output = self + .shell + .run_command("show mac-address", ExecutionMode::Regular) + .await?; + + output + .lines() + .skip(2) + .filter_map(|line| self.parse_mac_entry(line)) + .collect() + } + + async fn find_available_channel_id(&self) -> Result { + info!("[Brocade] Finding next available channel id..."); + + let output = self + .shell + .run_command("show lag", ExecutionMode::Regular) + .await?; + let re = Regex::new(r"=== LAG .* ID\s+(\d+)").expect("Invalid regex"); + + let used_ids: HashSet = output + .lines() + .filter_map(|line| { + re.captures(line) + .and_then(|c| c.get(1)) + .and_then(|id_match| id_match.as_str().parse().ok()) + }) + .collect(); + + let mut next_id: u8 = 1; + loop { + if !used_ids.contains(&next_id) { + break; + } + next_id += 1; + } + + info!("[Brocade] Found channel id: {next_id}"); + Ok(next_id) + } + + async fn create_port_channel( + &self, + channel_id: PortChannelId, + channel_name: &str, + ports: &[PortLocation], + ) -> Result<(), Error> { + info!( + "[Brocade] Configuring port-channel '{channel_name} {channel_id}' with ports: {ports:?}" + ); + + let commands = self.build_port_channel_commands(channel_name, channel_id, ports); + self.shell + .run_commands(commands, ExecutionMode::Privileged) + .await?; + + info!("[Brocade] Port-channel '{channel_name}' configured."); + Ok(()) + } + + async fn clear_port_channel(&self, channel_name: &str) -> Result<(), Error> { + debug!("[Brocade] Clearing port-channel: {channel_name}"); + + let commands = vec![ + "configure terminal".to_string(), + format!("no lag {channel_name}"), + "write memory".to_string(), + ]; + self.shell + .run_commands(commands, ExecutionMode::Privileged) + .await?; + + Ok(()) + } +} diff --git a/brocade/src/lib.rs b/brocade/src/lib.rs index 864dd49..6dc4436 100644 --- a/brocade/src/lib.rs +++ b/brocade/src/lib.rs @@ -1,57 +1,27 @@ +use std::net::IpAddr; use std::{ - borrow::Cow, - collections::HashSet, fmt::{self, Display}, - sync::Arc, time::Duration, }; +use crate::{ + fast_iron::FastIronClient, + shell::{BrocadeSession, BrocadeShell}, +}; + use async_trait::async_trait; -use harmony_types::net::{IpAddress, MacAddress}; -use log::{debug, info}; +use harmony_types::net::MacAddress; +use harmony_types::switch::{PortDeclaration, PortLocation}; use regex::Regex; -use russh::{ChannelMsg, client::Handler, kex::DH_G1_SHA1}; -use russh_keys::key::{self, SSH_RSA}; -use std::str::FromStr; -use tokio::time::{Instant, timeout}; -static PORT_CHANNEL_NAME: &str = "HARMONY_LAG"; - -#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Clone)] -pub struct MacAddressEntry { - pub vlan: u16, - pub mac_address: MacAddress, - pub port_name: String, -} - -impl Display for MacAddressEntry { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.write_str( - format!( - "VLAN\tMAC-Address\t\tPort\n{}\t{}\t{}", - self.vlan, self.mac_address, self.port_name - ) - .as_str(), - ) - } -} - -pub struct BrocadeClient { - ip: IpAddress, - user: UserConfig, - options: BrocadeOptions, -} - -#[derive(Default, Clone, Debug)] -struct UserConfig { - username: String, - password: String, -} +mod fast_iron; +mod shell; +mod ssh; #[derive(Default, Clone, Debug)] pub struct BrocadeOptions { pub dry_run: bool, - pub ssh: SshOptions, + pub ssh: ssh::SshOptions, pub timeouts: TimeoutConfig, } @@ -74,590 +44,127 @@ impl Default for TimeoutConfig { } } -#[derive(Clone, Debug)] -pub struct SshOptions { - pub preferred_algorithms: russh::Preferred, -} - -impl Default for SshOptions { - fn default() -> Self { - Self { - preferred_algorithms: russh::Preferred { - kex: Cow::Borrowed(&[DH_G1_SHA1]), - key: Cow::Borrowed(&[SSH_RSA]), - ..Default::default() - }, - } - } -} - enum ExecutionMode { Regular, Privileged, } -impl BrocadeClient { - pub async fn init( - ip_addresses: &[IpAddress], - username: &str, - password: &str, - options: Option, - ) -> Result { - let ip = ip_addresses - .first() - .ok_or_else(|| Error::ConfigurationError("No IP addresses provided".to_string()))?; +#[derive(Clone, Debug)] +pub struct BrocadeInfo { + os: BrocadeOs, + version: String, +} - let options = options.unwrap_or_default(); +#[derive(Clone, Debug)] +pub enum BrocadeOs { + NetworkOperatingSystem, + FastIron, + Unknown, +} - Ok(Self { - ip: *ip, - user: UserConfig { - username: username.to_string(), - password: password.to_string(), - }, - options, +#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Clone)] +pub struct MacAddressEntry { + pub vlan: u16, + pub mac_address: MacAddress, + pub port: PortDeclaration, +} + +pub type PortChannelId = u8; + +pub async fn init( + ip_addresses: &[IpAddr], + port: u16, + username: &str, + password: &str, + options: Option, +) -> Result, Error> { + let shell = BrocadeShell::init(ip_addresses, port, username, password, options).await?; + + let version_info = shell + .with_session(ExecutionMode::Regular, |session| { + Box::pin(get_brocade_info(session)) }) - } + .await?; - pub async fn show_mac_address_table(&self) -> Result, Error> { - info!("[Brocade] Showing MAC address table..."); + Ok(match version_info.os { + BrocadeOs::FastIron => Box::new(FastIronClient { + shell, + version: version_info, + }), + BrocadeOs::NetworkOperatingSystem => todo!(), + BrocadeOs::Unknown => todo!(), + }) +} - let output = self - .run_command("show mac-address", ExecutionMode::Regular) - .await?; +#[async_trait] +pub trait BrocadeClient { + async fn version(&self) -> Result; - output - .lines() - .skip(2) - .filter_map(|line| self.parse_mac_entry(line)) - .collect() - } + async fn show_mac_address_table(&self) -> Result, Error>; - pub async fn find_available_channel_id(&self) -> Result { - info!("[Brocade] Finding next available channel id..."); + async fn find_available_channel_id(&self) -> Result; - let output = self.run_command("show lag", ExecutionMode::Regular).await?; - let re = Regex::new(r"=== LAG .* ID\s+(\d+)").expect("Invalid regex"); - - let used_ids: HashSet = output - .lines() - .filter_map(|line| { - re.captures(line) - .and_then(|c| c.get(1)) - .and_then(|id_match| id_match.as_str().parse().ok()) - }) - .collect(); - - let mut next_id: u8 = 1; - loop { - if !used_ids.contains(&next_id) { - break; - } - next_id += 1; - } - - info!("[Brocade] Found channel id: {next_id}"); - Ok(next_id) - } - - pub async fn create_port_channel( + async fn create_port_channel( &self, + channel_id: PortChannelId, channel_name: &str, - channel_id: u8, - ports: &[String], - ) -> Result<(), Error> { - info!( - "[Brocade] Configuring port-channel '{channel_name} {channel_id}' with ports: {ports:?}" - ); + ports: &[PortLocation], + ) -> Result<(), Error>; - let commands = self.build_port_channel_commands(channel_name, channel_id, ports); - self.run_commands(commands, ExecutionMode::Privileged) - .await?; + async fn clear_port_channel(&self, channel_name: &str) -> Result<(), Error>; +} - info!("[Brocade] Port-channel '{PORT_CHANNEL_NAME}' configured."); - Ok(()) +async fn get_brocade_info(session: &mut BrocadeSession) -> Result { + let output = session.run_command("show version").await?; + + if output.contains("Network Operating System") { + let re = Regex::new(r"Network Operating System Version:\s*(?P[a-zA-Z0-9.\-]+)") + .expect("Invalid regex"); + let version = re + .captures(&output) + .and_then(|cap| cap.name("version")) + .map(|m| m.as_str().to_string()) + .unwrap_or_default(); + + return Ok(BrocadeInfo { + os: BrocadeOs::NetworkOperatingSystem, + version, + }); + } else if output.contains("ICX") { + let re = Regex::new(r"(?m)^\s*SW: Version\s*(?P[a-zA-Z0-9.\-]+)") + .expect("Invalid regex"); + let version = re + .captures(&output) + .and_then(|cap| cap.name("version")) + .map(|m| m.as_str().to_string()) + .unwrap_or_default(); + + return Ok(BrocadeInfo { + os: BrocadeOs::FastIron, + version, + }); } - pub async fn clear_port_channel(&self, channel_name: &str) -> Result<(), Error> { - debug!("[Brocade] Clearing port-channel: {channel_name}"); - - let commands = vec![ - "configure terminal".to_string(), - format!("no lag {channel_name}"), - "write memory".to_string(), - ]; - self.run_commands(commands, ExecutionMode::Privileged) - .await?; - - Ok(()) - } - - fn build_port_channel_commands( - &self, - channel_name: &str, - channel_id: u8, - ports: &[String], - ) -> Vec { - let mut commands = vec![ - "configure terminal".to_string(), - format!("lag {channel_name} static id {channel_id}"), - ]; - - for port in ports { - commands.push(format!("ports ethernet {port}")); - } - - commands.push(format!("primary-port {}", ports.first().unwrap())); - commands.push("deploy".into()); - commands.push("exit".into()); - commands.push("write memory".into()); - commands.push("exit".into()); - - commands - } - - async fn run_command(&self, command: &str, mode: ExecutionMode) -> Result { - if self.should_skip_command(command) { - return Ok(String::new()); - } - - let mut channel = self.open_session(&mode).await?; - - let output = self - .execute_command_in_session(&mut channel, command) - .await?; - let cleaned = self.clean_brocade_output(&output, command); - - self.close_session(channel, &mode).await?; - - Ok(cleaned) - } - - async fn run_commands(&self, commands: Vec, mode: ExecutionMode) -> Result<(), Error> { - if commands.is_empty() { - return Ok(()); - } - - let mut channel = self.open_session(&mode).await?; - - for command in commands { - if self.should_skip_command(&command) { - continue; - } - - self.execute_command_in_session(&mut channel, &command) - .await?; - } - - self.close_session(channel, &mode).await?; - - Ok(()) - } - - async fn open_session( - &self, - mode: &ExecutionMode, - ) -> Result, Error> { - let config = russh::client::Config { - preferred: self.options.ssh.preferred_algorithms.clone(), - ..Default::default() - }; - - let mut client = russh::client::connect(Arc::new(config), (self.ip, 22), Client {}).await?; - if !client - .authenticate_password(&self.user.username, &self.user.password) - .await? - { - return Err(Error::AuthenticationError( - "ssh authentication failed".to_string(), - )); - } - - let mut channel = client.channel_open_session().await?; - self.setup_channel(&mut channel, mode).await?; - - Ok(channel) - } - - async fn setup_channel( - &self, - channel: &mut russh::Channel, - mode: &ExecutionMode, - ) -> Result<(), Error> { - // Setup PTY and shell - channel - .request_pty(false, "vt100", 80, 24, 0, 0, &[]) - .await?; - channel.request_shell(false).await?; - - self.wait_for_shell_ready(channel).await?; - - match mode { - ExecutionMode::Regular => Ok(()), - ExecutionMode::Privileged => { - debug!("[Brocade] Attempting privilege escalation (enable mode)..."); - self.try_elevate_session(channel).await - } - } - } - - async fn execute_command_in_session( - &self, - channel: &mut russh::Channel, - command: &str, - ) -> Result { - debug!("[Brocade] Running command: '{command}'..."); - - channel.data(format!("{}\n", command).as_bytes()).await?; - tokio::time::sleep(Duration::from_millis(100)).await; - - let output = self.collect_command_output(channel).await?; - let output = String::from_utf8(output) - .map_err(|_| Error::UnexpectedError("Invalid UTF-8 in command output".to_string()))?; - - self.check_for_command_errors(&output, command)?; - - Ok(output) - } - - async fn try_elevate_session( - &self, - channel: &mut russh::Channel, - ) -> Result<(), Error> { - channel.data(&b"enable\n"[..]).await?; - let start = Instant::now(); - let mut buffer = Vec::new(); - - while start.elapsed() < self.options.timeouts.shell_ready { - match timeout(self.options.timeouts.message_wait, channel.wait()).await { - Ok(Some(ChannelMsg::Data { data })) => { - buffer.extend_from_slice(&data); - let output = String::from_utf8_lossy(&buffer); - - if output.ends_with('#') { - debug!("[Brocade] Privileged mode established"); - return Ok(()); - } - - if output.contains("User Name:") { - channel - .data(format!("{}\n", self.user.username).as_bytes()) - .await?; - buffer.clear(); - } else if output.contains("Password:") { - // Note: Brocade might not echo the password field - channel - .data(format!("{}\n", self.user.password).as_bytes()) - .await?; - buffer.clear(); - } else if output.contains('>') { - // Back to user mode, something failed (e.g., wrong password) - return Err(Error::AuthenticationError( - "Enable authentication failed or access denied.".to_string(), - )); - } - } - Ok(Some(_)) => continue, - Ok(None) => break, - Err(_) => continue, - } - } - - // Check final state if timeout was reached - let output = String::from_utf8_lossy(&buffer); - let elevated = output.ends_with('#'); - match elevated { - true => { - debug!("[Brocade] Privileged mode established"); - Ok(()) - } - false => Err(Error::AuthenticationError(format!( - "Enable authentication failed for an unknown reason. Output was:\n{output}", - ))), - } - } - - async fn wait_for_shell_ready( - &self, - channel: &mut russh::Channel, - ) -> Result<(), Error> { - let mut buffer = Vec::new(); - let start = Instant::now(); - - while start.elapsed() < self.options.timeouts.shell_ready { - match timeout(self.options.timeouts.message_wait, channel.wait()).await { - Ok(Some(ChannelMsg::Data { data })) => { - buffer.extend_from_slice(&data); - let output = String::from_utf8_lossy(&buffer); - if output.ends_with('>') || output.ends_with('#') { - debug!("[Brocade] Shell ready: {}", output.trim()); - return Ok(()); - } - } - Ok(Some(_)) => continue, - Ok(None) => break, - Err(_) => continue, - } - } - Ok(()) - } - - async fn collect_command_output( - &self, - channel: &mut russh::Channel, - ) -> Result, Error> { - let mut output = Vec::new(); - let start = Instant::now(); - - let read_timeout = Duration::from_millis(500); - - let log_interval = Duration::from_secs(3); - let mut last_log = Instant::now(); - - loop { - if start.elapsed() > self.options.timeouts.command_execution { - return Err(Error::TimeoutError( - "Timeout waiting for command completion.".to_string(), - )); - } - - if start.elapsed() > Duration::from_secs(5) && last_log.elapsed() > log_interval { - info!("[Brocade] Waiting for command output..."); - last_log = Instant::now(); - } - - match timeout(read_timeout, channel.wait()).await { - Ok(Some(ChannelMsg::Data { data } | ChannelMsg::ExtendedData { data, .. })) => { - output.extend_from_slice(&data); - - let current_output = String::from_utf8_lossy(&output); - if current_output.contains('>') || current_output.contains('#') { - return Ok(output); - } - } - - Ok(Some(ChannelMsg::Eof | ChannelMsg::Close)) => { - return Ok(output); - } - - Ok(Some(ChannelMsg::ExitStatus { exit_status })) => { - debug!("[Brocade] Command exit status: {exit_status}"); - continue; - } - - Ok(Some(_)) => continue, // Ignore other channel messages - Ok(None) | Err(_) => { - if output.is_empty() { - if let Ok(None) = timeout(read_timeout, channel.wait()).await { - // Check one last time if channel is closed - break; - } - continue; - } - - // If we received a timeout (Err) and have output, wait a short time to check for a late prompt - tokio::time::sleep(Duration::from_millis(100)).await; - - let current_output = String::from_utf8_lossy(&output); - if current_output.contains('>') || current_output.contains('#') { - return Ok(output); - } - - continue; - } - } - } - - Ok(output) - } - - async fn close_session( - &self, - mut channel: russh::Channel, - mode: &ExecutionMode, - ) -> Result<(), Error> { - debug!("[Brocade] Closing session..."); - - channel.data(&b"exit\n"[..]).await?; - if let ExecutionMode::Privileged = mode { - channel.data(&b"exit\n"[..]).await?; // Previous exit closed "enable" mode - } - - let start = Instant::now(); - - while start.elapsed() < self.options.timeouts.cleanup { - match timeout(self.options.timeouts.message_wait, channel.wait()).await { - Ok(Some(ChannelMsg::Close)) => break, - Ok(Some(_)) => continue, - Ok(None) | Err(_) => break, - } - } - - debug!("[Brocade] Session '{}' closed, bye bye.", channel.id()); - - Ok(()) - } - - fn should_skip_command(&self, command: &str) -> bool { - if (command.starts_with("write") || command.starts_with("deploy")) && self.options.dry_run { - info!("[Brocade] Dry-run mode enabled, skipping command: {command}"); - return true; - } - false - } - - fn parse_mac_entry(&self, line: &str) -> Option> { - debug!("[Brocade] Parsing mac address entry: {line}"); - let parts: Vec<&str> = line.split_whitespace().collect(); - if parts.len() < 3 { - return None; - } - - let (vlan, mac_address, port_name) = match parts.len() { - 3 => ( - // Format: VLAN/MAC/Port - u16::from_str(parts[0]).ok()?, - parse_brocade_mac_address(parts[1]).ok()?, - parts[2].to_string(), - ), - _ => ( - // Format: MAC/Port/Type/Index, default VLAN usually 1 - 1, - parse_brocade_mac_address(parts[0]).ok()?, - parts[1].to_string(), - ), - }; - - Some(Ok(MacAddressEntry { - vlan, - mac_address, - port_name, - })) - } - - fn clean_brocade_output(&self, raw_output: &str, command: &str) -> String { - debug!("[Brocade] Received raw output:\n{raw_output}"); - - let lines: Vec<&str> = raw_output.lines().collect(); - let mut cleaned_lines = Vec::new(); - let mut output_started = false; - let mut command_echo_found = false; - - for line in lines { - let trimmed = line.trim(); - - if !output_started && trimmed.is_empty() { - continue; - } - - if !command_echo_found && trimmed.contains(command) { - command_echo_found = true; - output_started = true; - continue; - } - - if self.is_prompt_line(trimmed) { - if output_started && !cleaned_lines.is_empty() { - break; - } - continue; - } - - if trimmed == "exit" { - break; - } - - if output_started && !trimmed.is_empty() { - cleaned_lines.push(line); - } - } - - // Remove trailing empty lines - while cleaned_lines.last() == Some(&"") { - cleaned_lines.pop(); - } - - let output = cleaned_lines.join("\n"); - debug!("[Brocade] Command output:\n{output}"); - - output - } - - fn is_prompt_line(&self, line: &str) -> bool { - line.ends_with('#') || line.ends_with('>') || line.starts_with("SSH@") - } - - fn check_for_command_errors(&self, output: &str, command: &str) -> Result<(), Error> { - const ERROR_PATTERNS: &[&str] = &[ - "invalid input", - "syntax error", - "command not found", - "unknown command", - "permission denied", - "access denied", - "authentication failed", - "configuration error", - "failed to", - "error:", - ]; - - let output_lower = output.to_lowercase(); - - if ERROR_PATTERNS.iter().any(|&p| output_lower.contains(p)) { - return Err(Error::CommandError(format!( - "Command '{}' failed: {}", - command, - output.trim() - ))); - } - - if !command.starts_with("show") && output.trim().is_empty() { - return Err(Error::CommandError(format!( - "Command '{}' produced no output, which may indicate an error", - command - ))); - } - - Ok(()) - } + Err(Error::UnexpectedError("Unknown Brocade OS version".into())) } fn parse_brocade_mac_address(value: &str) -> Result { - // Remove periods from the Brocade format let cleaned_mac = value.replace('.', ""); - // Ensure the cleaned string has the correct length for a MAC address if cleaned_mac.len() != 12 { - return Err(format!("Invalid MAC address: {value}",)); + return Err(format!("Invalid MAC address: {value}")); } - // Parse the hexadecimal string into bytes let mut bytes = [0u8; 6]; for (i, pair) in cleaned_mac.as_bytes().chunks(2).enumerate() { - let byte_str = - std::str::from_utf8(pair).map_err(|_| "Invalid UTF-8 sequence".to_string())?; - - bytes[i] = u8::from_str_radix(byte_str, 16) - .map_err(|_| format!("Invalid hex byte in MAC address: {value}"))?; + let byte_str = std::str::from_utf8(pair).map_err(|_| "Invalid UTF-8")?; + bytes[i] = + u8::from_str_radix(byte_str, 16).map_err(|_| format!("Invalid hex in MAC: {value}"))?; } Ok(MacAddress(bytes)) } -struct Client; - -#[async_trait] -impl Handler for Client { - type Error = Error; - - async fn check_server_key( - &mut self, - _server_public_key: &key::PublicKey, - ) -> Result { - Ok(true) - } -} - #[derive(Debug)] pub enum Error { NetworkError(String), diff --git a/brocade/src/shell.rs b/brocade/src/shell.rs new file mode 100644 index 0000000..643caa6 --- /dev/null +++ b/brocade/src/shell.rs @@ -0,0 +1,330 @@ +use std::net::IpAddr; +use std::time::Duration; +use std::time::Instant; + +use crate::BrocadeOptions; +use crate::Error; +use crate::ExecutionMode; +use crate::TimeoutConfig; +use crate::ssh; + +use log::debug; +use log::info; +use russh::ChannelMsg; +use tokio::time::timeout; + +pub struct BrocadeShell { + pub ip: IpAddr, + pub port: u16, + pub username: String, + pub password: String, + pub options: BrocadeOptions, +} + +impl BrocadeShell { + pub async fn init( + ip_addresses: &[IpAddr], + port: u16, + username: &str, + password: &str, + options: Option, + ) -> Result { + let ip = ip_addresses + .first() + .ok_or_else(|| Error::ConfigurationError("No IP addresses provided".to_string()))?; + + let base_options = options.unwrap_or_default(); + let options = ssh::try_init_client(username, password, ip, base_options).await?; + + Ok(Self { + ip: *ip, + port, + username: username.to_string(), + password: password.to_string(), + options, + }) + } + + pub async fn open_session(&self, mode: ExecutionMode) -> Result { + BrocadeSession::open( + self.ip, + self.port, + &self.username, + &self.password, + self.options.clone(), + mode, + ) + .await + } + + pub async fn with_session(&self, mode: ExecutionMode, callback: F) -> Result + where + F: FnOnce( + &mut BrocadeSession, + ) -> std::pin::Pin< + Box> + Send + '_>, + >, + { + let mut session = self.open_session(mode).await?; + let result = callback(&mut session).await; + session.close().await?; + result + } + + pub async fn run_command(&self, command: &str, mode: ExecutionMode) -> Result { + let mut session = self.open_session(mode).await?; + let result = session.run_command(command).await; + session.close().await?; + result + } + + pub async fn run_commands( + &self, + commands: Vec, + mode: ExecutionMode, + ) -> Result<(), Error> { + let mut session = self.open_session(mode).await?; + let result = session.run_commands(commands).await; + session.close().await?; + result + } +} + +pub struct BrocadeSession { + pub channel: russh::Channel, + pub mode: ExecutionMode, + pub options: BrocadeOptions, +} + +impl BrocadeSession { + pub async fn open( + ip: IpAddr, + port: u16, + username: &str, + password: &str, + options: BrocadeOptions, + mode: ExecutionMode, + ) -> Result { + let client = ssh::create_client(ip, port, username, password, &options).await?; + let mut channel = client.channel_open_session().await?; + + channel + .request_pty(false, "vt100", 80, 24, 0, 0, &[]) + .await?; + channel.request_shell(false).await?; + + wait_for_shell_ready(&mut channel, &options.timeouts).await?; + + if let ExecutionMode::Privileged = mode { + try_elevate_session(&mut channel, username, password, &options.timeouts).await?; + } + + Ok(Self { + channel, + mode, + options, + }) + } + + pub async fn close(&mut self) -> Result<(), Error> { + debug!("[Brocade] Closing session..."); + + self.channel.data(&b"exit\n"[..]).await?; + if let ExecutionMode::Privileged = self.mode { + self.channel.data(&b"exit\n"[..]).await?; + } + + let start = Instant::now(); + while start.elapsed() < self.options.timeouts.cleanup { + match timeout(self.options.timeouts.message_wait, self.channel.wait()).await { + Ok(Some(ChannelMsg::Close)) => break, + Ok(Some(_)) => continue, + Ok(None) | Err(_) => break, + } + } + + debug!("[Brocade] Session closed."); + Ok(()) + } + + pub async fn run_command(&mut self, command: &str) -> Result { + debug!("[Brocade] Running command: '{command}'..."); + + self.channel + .data(format!("{}\n", command).as_bytes()) + .await?; + tokio::time::sleep(Duration::from_millis(100)).await; + + let output = self.collect_command_output().await?; + let output = String::from_utf8(output) + .map_err(|_| Error::UnexpectedError("Invalid UTF-8 in command output".to_string()))?; + + self.check_for_command_errors(&output, command)?; + Ok(output) + } + + pub async fn run_commands(&mut self, commands: Vec) -> Result<(), Error> { + for command in commands { + self.run_command(&command).await?; + } + Ok(()) + } + + pub async fn collect_command_output(&mut self) -> Result, Error> { + let mut output = Vec::new(); + let start = Instant::now(); + let read_timeout = Duration::from_millis(500); + let log_interval = Duration::from_secs(3); + let mut last_log = Instant::now(); + + loop { + if start.elapsed() > self.options.timeouts.command_execution { + return Err(Error::TimeoutError( + "Timeout waiting for command completion.".into(), + )); + } + + if start.elapsed() > Duration::from_secs(5) && last_log.elapsed() > log_interval { + info!("[Brocade] Waiting for command output..."); + last_log = Instant::now(); + } + + match timeout(read_timeout, self.channel.wait()).await { + Ok(Some(ChannelMsg::Data { data } | ChannelMsg::ExtendedData { data, .. })) => { + output.extend_from_slice(&data); + let current_output = String::from_utf8_lossy(&output); + if current_output.contains('>') || current_output.contains('#') { + return Ok(output); + } + } + Ok(Some(ChannelMsg::Eof | ChannelMsg::Close)) => return Ok(output), + Ok(Some(ChannelMsg::ExitStatus { exit_status })) => { + debug!("[Brocade] Command exit status: {exit_status}"); + } + Ok(Some(_)) => continue, + Ok(None) | Err(_) => { + if output.is_empty() { + if let Ok(None) = timeout(read_timeout, self.channel.wait()).await { + break; + } + continue; + } + + tokio::time::sleep(Duration::from_millis(100)).await; + let current_output = String::from_utf8_lossy(&output); + if current_output.contains('>') || current_output.contains('#') { + return Ok(output); + } + } + } + } + + Ok(output) + } + + pub fn check_for_command_errors(&self, output: &str, command: &str) -> Result<(), Error> { + const ERROR_PATTERNS: &[&str] = &[ + "invalid input", + "syntax error", + "command not found", + "unknown command", + "permission denied", + "access denied", + "authentication failed", + "configuration error", + "failed to", + "error:", + ]; + + let output_lower = output.to_lowercase(); + if ERROR_PATTERNS.iter().any(|&p| output_lower.contains(p)) { + return Err(Error::CommandError(format!( + "Command '{command}' failed: {}", + output.trim() + ))); + } + + if !command.starts_with("show") && output.trim().is_empty() { + return Err(Error::CommandError(format!( + "Command '{command}' produced no output" + ))); + } + + Ok(()) + } +} + +pub async fn wait_for_shell_ready( + channel: &mut russh::Channel, + timeouts: &TimeoutConfig, +) -> Result<(), Error> { + let mut buffer = Vec::new(); + let start = Instant::now(); + + while start.elapsed() < timeouts.shell_ready { + match timeout(timeouts.message_wait, channel.wait()).await { + Ok(Some(ChannelMsg::Data { data })) => { + buffer.extend_from_slice(&data); + let output = String::from_utf8_lossy(&buffer); + if output.ends_with('>') || output.ends_with('#') { + debug!("[Brocade] Shell ready"); + return Ok(()); + } + } + Ok(Some(_)) => continue, + Ok(None) => break, + Err(_) => continue, + } + } + Ok(()) +} + +pub async fn try_elevate_session( + channel: &mut russh::Channel, + username: &str, + password: &str, + timeouts: &TimeoutConfig, +) -> Result<(), Error> { + channel.data(&b"enable\n"[..]).await?; + let start = Instant::now(); + let mut buffer = Vec::new(); + + while start.elapsed() < timeouts.shell_ready { + match timeout(timeouts.message_wait, channel.wait()).await { + Ok(Some(ChannelMsg::Data { data })) => { + buffer.extend_from_slice(&data); + let output = String::from_utf8_lossy(&buffer); + + if output.ends_with('#') { + debug!("[Brocade] Privileged mode established"); + return Ok(()); + } + + if output.contains("User Name:") { + channel.data(format!("{}\n", username).as_bytes()).await?; + buffer.clear(); + } else if output.contains("Password:") { + channel.data(format!("{}\n", password).as_bytes()).await?; + buffer.clear(); + } else if output.contains('>') { + return Err(Error::AuthenticationError( + "Enable authentication failed".into(), + )); + } + } + Ok(Some(_)) => continue, + Ok(None) => break, + Err(_) => continue, + } + } + + let output = String::from_utf8_lossy(&buffer); + if output.ends_with('#') { + debug!("[Brocade] Privileged mode established"); + Ok(()) + } else { + Err(Error::AuthenticationError(format!( + "Enable failed. Output:\n{output}" + ))) + } +} diff --git a/brocade/src/ssh.rs b/brocade/src/ssh.rs new file mode 100644 index 0000000..08ff96f --- /dev/null +++ b/brocade/src/ssh.rs @@ -0,0 +1,113 @@ +use std::borrow::Cow; +use std::sync::Arc; + +use async_trait::async_trait; +use russh::client::Handler; +use russh::kex::DH_G1_SHA1; +use russh::kex::ECDH_SHA2_NISTP256; +use russh_keys::key::SSH_RSA; + +use super::BrocadeOptions; +use super::Error; + +#[derive(Default, Clone, Debug)] +pub struct SshOptions { + pub preferred_algorithms: russh::Preferred, +} + +impl SshOptions { + fn ecdhsa_sha2_nistp256() -> Self { + Self { + preferred_algorithms: russh::Preferred { + kex: Cow::Borrowed(&[ECDH_SHA2_NISTP256]), + key: Cow::Borrowed(&[SSH_RSA]), + ..Default::default() + }, + } + } + + fn legacy() -> Self { + Self { + preferred_algorithms: russh::Preferred { + kex: Cow::Borrowed(&[DH_G1_SHA1]), + key: Cow::Borrowed(&[SSH_RSA]), + ..Default::default() + }, + } + } +} + +pub struct Client; + +#[async_trait] +impl Handler for Client { + type Error = Error; + + async fn check_server_key( + &mut self, + _server_public_key: &russh_keys::key::PublicKey, + ) -> Result { + Ok(true) + } +} + +pub async fn try_init_client( + username: &str, + password: &str, + ip: &std::net::IpAddr, + base_options: BrocadeOptions, +) -> Result { + let ssh_options = vec![ + SshOptions::default(), + SshOptions::ecdhsa_sha2_nistp256(), + SshOptions::legacy(), + ]; + + for ssh in ssh_options { + let opts = BrocadeOptions { + ssh, + ..base_options.clone() + }; + let client = create_client(*ip, 22, username, password, &opts).await; + + match client { + Ok(_) => { + return Ok(opts); + } + Err(e) => match e { + Error::NetworkError(e) => { + if e.contains("No common key exchange algorithm") { + continue; + } else { + return Err(Error::NetworkError(e)); + } + } + _ => return Err(e), + }, + } + } + + Err(Error::NetworkError( + "Could not establish ssh connection: wrong key exchange algorithm)".to_string(), + )) +} + +pub async fn create_client( + ip: std::net::IpAddr, + port: u16, + username: &str, + password: &str, + options: &BrocadeOptions, +) -> Result, Error> { + let config = russh::client::Config { + preferred: options.ssh.preferred_algorithms.clone(), + ..Default::default() + }; + let mut client = russh::client::connect(Arc::new(config), (ip, port), Client {}).await?; + if !client.authenticate_password(username, password).await? { + return Err(Error::AuthenticationError( + "ssh authentication failed".to_string(), + )); + } + Ok(client) +} diff --git a/harmony/src/domain/topology/ha_cluster.rs b/harmony/src/domain/topology/ha_cluster.rs index 1052733..8c65e70 100644 --- a/harmony/src/domain/topology/ha_cluster.rs +++ b/harmony/src/domain/topology/ha_cluster.rs @@ -4,6 +4,7 @@ use harmony_macros::ip; use harmony_secret::SecretManager; use harmony_types::net::MacAddress; use harmony_types::net::Url; +use harmony_types::switch::PortLocation; use k8s_openapi::api::core::v1::Namespace; use kube::api::ObjectMeta; use log::debug; @@ -326,11 +327,7 @@ impl HAClusterTopology { debug!("Configuring port channel: {config:#?}"); let client = self.get_switch_client().await?; - let switch_ports: Vec = config - .switch_ports - .iter() - .map(|s| s.port_name.clone()) - .collect(); + let switch_ports = config.switch_ports.iter().map(|s| s.port.clone()).collect(); client .configure_port_channel(&format!("Harmony_{}", host.id), switch_ports) @@ -519,7 +516,7 @@ impl Switch for HAClusterTopology { async fn get_port_for_mac_address( &self, mac_address: &MacAddress, - ) -> Result, SwitchError> { + ) -> Result, SwitchError> { let client = self.get_switch_client().await?; let port = client.find_port(mac_address).await?; Ok(port) diff --git a/harmony/src/domain/topology/network.rs b/harmony/src/domain/topology/network.rs index 81487bb..60417b0 100644 --- a/harmony/src/domain/topology/network.rs +++ b/harmony/src/domain/topology/network.rs @@ -2,7 +2,10 @@ use std::{error::Error, net::Ipv4Addr, str::FromStr, sync::Arc}; use async_trait::async_trait; use derive_new::new; -use harmony_types::net::{IpAddress, MacAddress}; +use harmony_types::{ + net::{IpAddress, MacAddress}, + switch::PortLocation, +}; use serde::Serialize; use crate::{executors::ExecutorError, hardware::PhysicalHost}; @@ -178,7 +181,7 @@ pub trait Switch: Send + Sync { async fn get_port_for_mac_address( &self, mac_address: &MacAddress, - ) -> Result, SwitchError>; + ) -> Result, SwitchError>; async fn configure_host_network( &self, @@ -195,7 +198,7 @@ pub struct HostNetworkConfig { #[derive(Clone, Debug, PartialEq)] pub struct SwitchPort { pub interface: NetworkInterface, - pub port_name: String, + pub port: PortLocation, } #[derive(Clone, Debug, PartialEq)] @@ -221,12 +224,15 @@ impl Error for SwitchError {} #[async_trait] pub trait SwitchClient: Send + Sync { - async fn find_port(&self, mac_address: &MacAddress) -> Result, SwitchError>; + async fn find_port( + &self, + mac_address: &MacAddress, + ) -> Result, SwitchError>; async fn configure_port_channel( &self, channel_name: &str, - switch_ports: Vec, + switch_ports: Vec, ) -> Result; } diff --git a/harmony/src/infra/brocade.rs b/harmony/src/infra/brocade.rs index fc98e3d..3734527 100644 --- a/harmony/src/infra/brocade.rs +++ b/harmony/src/infra/brocade.rs @@ -1,13 +1,16 @@ use async_trait::async_trait; use brocade::{BrocadeClient, BrocadeOptions}; use harmony_secret::Secret; -use harmony_types::net::{IpAddress, MacAddress}; +use harmony_types::{ + net::{IpAddress, MacAddress}, + switch::{PortDeclaration, PortLocation}, +}; use serde::{Deserialize, Serialize}; use crate::topology::{SwitchClient, SwitchError}; pub struct BrocadeSwitchClient { - brocade: BrocadeClient, + brocade: Box, } impl BrocadeSwitchClient { @@ -17,30 +20,44 @@ impl BrocadeSwitchClient { password: &str, options: Option, ) -> Result { - let brocade = BrocadeClient::init(ip_addresses, username, password, options).await?; + let brocade = brocade::init(ip_addresses, 22, username, password, options).await?; Ok(Self { brocade }) } } #[async_trait] impl SwitchClient for BrocadeSwitchClient { - async fn find_port(&self, mac_address: &MacAddress) -> Result, SwitchError> { + async fn find_port( + &self, + mac_address: &MacAddress, + ) -> Result, SwitchError> { let table = self .brocade .show_mac_address_table() .await .map_err(|e| SwitchError::new(format!("{e}")))?; - Ok(table + let port = table .iter() .find(|entry| entry.mac_address == *mac_address) - .map(|entry| entry.port_name.clone())) + .map(|entry| match &entry.port { + PortDeclaration::Single(port_location) => Ok(port_location.clone()), + _ => Err(SwitchError::new( + "Multiple ports found for MAC address".into(), + )), + }); + + match port { + Some(Ok(p)) => Ok(Some(p)), + Some(Err(e)) => Err(e), + None => Ok(None), + } } async fn configure_port_channel( &self, channel_name: &str, - switch_ports: Vec, + switch_ports: Vec, ) -> Result { let channel_id = self .brocade @@ -49,7 +66,7 @@ impl SwitchClient for BrocadeSwitchClient { .map_err(|e| SwitchError::new(format!("{e}")))?; self.brocade - .create_port_channel(channel_name, channel_id, &switch_ports) + .create_port_channel(channel_id, channel_name, &switch_ports) .await .map_err(|e| SwitchError::new(format!("{e}")))?; diff --git a/harmony/src/modules/okd/host_network.rs b/harmony/src/modules/okd/host_network.rs index 873830d..58a5e98 100644 --- a/harmony/src/modules/okd/host_network.rs +++ b/harmony/src/modules/okd/host_network.rs @@ -71,7 +71,7 @@ impl Interpret for HostNetworkConfigurationInterpret { let mac_address = network_interface.mac_address; match topology.get_port_for_mac_address(&mac_address).await { - Ok(Some(port_name)) => { + Ok(Some(port)) => { switch_ports.push(SwitchPort { interface: NetworkInterface { name: network_interface.name.clone(), @@ -79,7 +79,7 @@ impl Interpret for HostNetworkConfigurationInterpret { speed_mbps: network_interface.speed_mbps, mtu: network_interface.mtu, }, - port_name, + port, }); } Ok(None) => debug!("No port found for host '{}', skipping", host.id), @@ -110,7 +110,7 @@ impl Interpret for HostNetworkConfigurationInterpret { #[cfg(test)] mod tests { use assertor::*; - use harmony_types::net::MacAddress; + use harmony_types::{net::MacAddress, switch::PortLocation}; use lazy_static::lazy_static; use crate::{ @@ -147,8 +147,8 @@ mod tests { speed_mbps: None, mtu: 1, }; - pub static ref PORT: String = "1/0/42".into(); - pub static ref ANOTHER_PORT: String = "2/0/42".into(); + pub static ref PORT: PortLocation = PortLocation(1, 0, 42); + pub static ref ANOTHER_PORT: PortLocation = PortLocation(2, 0, 42); } #[tokio::test] @@ -165,7 +165,7 @@ mod tests { HostNetworkConfig { switch_ports: vec![SwitchPort { interface: EXISTING_INTERFACE.clone(), - port_name: PORT.clone(), + port: PORT.clone(), }], }, )]); @@ -191,11 +191,11 @@ mod tests { switch_ports: vec![ SwitchPort { interface: EXISTING_INTERFACE.clone(), - port_name: PORT.clone(), + port: PORT.clone(), }, SwitchPort { interface: ANOTHER_EXISTING_INTERFACE.clone(), - port_name: ANOTHER_PORT.clone(), + port: ANOTHER_PORT.clone(), }, ], }, @@ -219,7 +219,7 @@ mod tests { HostNetworkConfig { switch_ports: vec![SwitchPort { interface: EXISTING_INTERFACE.clone(), - port_name: PORT.clone(), + port: PORT.clone(), }], }, ), @@ -228,7 +228,7 @@ mod tests { HostNetworkConfig { switch_ports: vec![SwitchPort { interface: ANOTHER_EXISTING_INTERFACE.clone(), - port_name: ANOTHER_PORT.clone(), + port: ANOTHER_PORT.clone(), }], }, ), @@ -282,7 +282,7 @@ mod tests { } struct TopologyWithSwitch { - available_ports: Arc>>, + available_ports: Arc>>, configured_host_networks: Arc>>, } @@ -318,7 +318,7 @@ mod tests { async fn get_port_for_mac_address( &self, _mac_address: &MacAddress, - ) -> Result, SwitchError> { + ) -> Result, SwitchError> { let mut ports = self.available_ports.lock().unwrap(); if ports.is_empty() { return Ok(None); diff --git a/harmony_types/src/id.rs b/harmony_types/src/id.rs index 2cb2674..0a82906 100644 --- a/harmony_types/src/id.rs +++ b/harmony_types/src/id.rs @@ -19,7 +19,7 @@ use serde::{Deserialize, Serialize}; /// /// **It is not meant to be very secure or unique**, it is suitable to generate up to 10 000 items per /// second with a reasonable collision rate of 0,000014 % as calculated by this calculator : https://kevingal.com/apps/collision.html -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[derive(Debug, Clone, Eq, PartialEq, PartialOrd, Ord, Serialize, Deserialize)] pub struct Id { value: String, } diff --git a/harmony_types/src/lib.rs b/harmony_types/src/lib.rs index 7bb1abd..098379a 100644 --- a/harmony_types/src/lib.rs +++ b/harmony_types/src/lib.rs @@ -1,2 +1,3 @@ pub mod id; pub mod net; +pub mod switch; diff --git a/harmony_types/src/switch.rs b/harmony_types/src/switch.rs new file mode 100644 index 0000000..6b8de88 --- /dev/null +++ b/harmony_types/src/switch.rs @@ -0,0 +1,176 @@ +use std::{fmt, str::FromStr}; + +/// Simple error type for port parsing failures. +#[derive(Debug)] +pub enum PortParseError { + /// The port string did not conform to the expected S/M/P or range format. + InvalidFormat, + /// A stack, module, or port segment could not be parsed as a number. + InvalidSegment(String), +} + +impl fmt::Display for PortParseError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + PortParseError::InvalidFormat => write!(f, "Port string is in an unexpected format."), + PortParseError::InvalidSegment(s) => write!(f, "Invalid segment in port string: {}", s), + } + } +} + +/// Represents the atomic, physical location of a switch port: `//`. +/// +/// Example: `1/1/1` +#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Clone)] +pub struct PortLocation(pub u8, pub u8, pub u8); + +impl fmt::Display for PortLocation { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}/{}/{}", self.0, self.1, self.2) + } +} + +impl FromStr for PortLocation { + type Err = PortParseError; + + /// Parses a string slice into a `PortLocation`. + /// + /// # Examples + /// + /// ```rust + /// use std::str::FromStr; + /// use brocade::port::PortLocation; + /// + /// assert_eq!(PortLocation::from_str("1/1/1").unwrap(), PortLocation(1, 1, 1)); + /// assert_eq!(PortLocation::from_str("12/5/48").unwrap(), PortLocation(12, 5, 48)); + /// assert!(PortLocation::from_str("1/A/1").is_err()); + /// ``` + fn from_str(s: &str) -> Result { + let parts: Vec<&str> = s.split('/').collect(); + + if parts.len() != 3 { + return Err(PortParseError::InvalidFormat); + } + + let parse_segment = |part: &str| -> Result { + u8::from_str(part).map_err(|_| PortParseError::InvalidSegment(part.to_string())) + }; + + let stack = parse_segment(parts[0])?; + let module = parse_segment(parts[1])?; + let port = parse_segment(parts[2])?; + + Ok(PortLocation(stack, module, port)) + } +} + +/// Represents a Brocade Port configuration input, which can be a single port, a sequential +/// range, or an explicit set defined by endpoints. +#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Clone)] +pub enum PortDeclaration { + /// A single switch port defined by its location. Example: `PortDeclaration::Single(1/1/1)` + Single(PortLocation), + /// A strictly sequential range defined by two endpoints using the hyphen separator (`-`). + /// All ports between the endpoints (inclusive) are implicitly included. + /// Example: `PortDeclaration::Range(1/1/1, 1/1/4)` + Range(PortLocation, PortLocation), + /// A set of ports defined by two endpoints using the asterisk separator (`*`). + /// The actual member ports must be determined contextually (e.g., from MAC tables or + /// explicit configuration lists). + /// Example: `PortDeclaration::Set(1/1/1, 1/1/3)` where only ports 1 and 3 might be active. + Set(PortLocation, PortLocation), +} + +impl PortDeclaration { + /// Parses a Brocade port configuration string into a structured `PortDeclaration` enum. + /// + /// This function performs only basic format and numerical parsing, assuming the input + /// strings (e.g., from `show` commands) are semantically valid and logically ordered. + /// + /// # Supported Formats + /// + /// * **Single Port:** `"1/1/1"` + /// * **Range (Hyphen, `-`):** `"1/1/1-1/1/4"` + /// * **Set (Asterisk, `*`):** `"1/1/1*1/1/4"` + /// + /// # Errors + /// + /// Returns `PortParseError` if the string format is incorrect or numerical segments + /// cannot be parsed. + /// + /// # Examples + /// + /// ```rust + /// use brocade::port::{PortDeclaration, PortLocation}; + /// + /// // Single Port + /// assert_eq!(PortDeclaration::parse("3/2/15").unwrap(), PortDeclaration::Single(PortLocation(3, 2, 15))); + /// + /// // Range (Hyphen) - implies sequential ports + /// let result_range = PortDeclaration::parse("1/1/1-1/1/4").unwrap(); + /// assert_eq!(result_range, PortDeclaration::Range(PortLocation(1, 1, 1), PortLocation(1, 1, 4))); + /// + /// // Set (Asterisk) - implies non-sequential set defined by endpoints + /// let result_set = PortDeclaration::parse("1/1/48*2/1/48").unwrap(); + /// assert_eq!(result_set, PortDeclaration::Set(PortLocation(1, 1, 48), PortLocation(2, 1, 48))); + /// + /// // Invalid Format (will still fail basic parsing) + /// assert!(PortDeclaration::parse("1/1/1/1").is_err()); + /// ``` + pub fn parse(port_str: &str) -> Result { + if let Some((start_str, end_str)) = port_str.split_once('-') { + let start_port = PortLocation::from_str(start_str.trim())?; + let end_port = PortLocation::from_str(end_str.trim())?; + return Ok(PortDeclaration::Range(start_port, end_port)); + } + + if let Some((start_str, end_str)) = port_str.split_once('*') { + let start_port = PortLocation::from_str(start_str.trim())?; + let end_port = PortLocation::from_str(end_str.trim())?; + return Ok(PortDeclaration::Set(start_port, end_port)); + } + + let location = PortLocation::from_str(port_str)?; + Ok(PortDeclaration::Single(location)) + } +} + +impl fmt::Display for PortDeclaration { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + PortDeclaration::Single(port) => write!(f, "{port}"), + PortDeclaration::Range(start, end) => write!(f, "{start}-{end}"), + PortDeclaration::Set(start, end) => write!(f, "{start}*{end}"), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_single_port_location_invalid() { + assert!(PortLocation::from_str("1/1").is_err()); + assert!(PortLocation::from_str("1/A/1").is_err()); + assert!(PortLocation::from_str("1/1/256").is_err()); + } + + #[test] + fn test_parse_declaration_single() { + let single_result = PortDeclaration::parse("1/1/4").unwrap(); + assert!(matches!(single_result, PortDeclaration::Single(_))); + } + + #[test] + fn test_parse_declaration_range() { + let range_result = PortDeclaration::parse("1/1/1-1/1/4").unwrap(); + assert!(matches!(range_result, PortDeclaration::Range(_, _))); + } + + #[test] + fn test_parse_declaration_set() { + let set_result = PortDeclaration::parse("1/1/48*2/1/48").unwrap(); + assert!(matches!(set_result, PortDeclaration::Set(_, _))); + } +}