refactor brocade to support different shell versions (e.g. FastIron vs NOS)
Some checks failed
Run Check Script / check (pull_request) Failing after 1m17s
Some checks failed
Run Check Script / check (pull_request) Failing after 1m17s
This commit is contained in:
parent
45e0de2097
commit
ad61be277b
34
Cargo.lock
generated
34
Cargo.lock
generated
@ -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",
|
||||
|
@ -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();
|
||||
|
||||
|
161
brocade/src/fast_iron.rs
Normal file
161
brocade/src/fast_iron.rs
Normal file
@ -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<Result<MacAddressEntry, Error>> {
|
||||
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<String> {
|
||||
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<BrocadeInfo, Error> {
|
||||
Ok(self.version.clone())
|
||||
}
|
||||
|
||||
async fn show_mac_address_table(&self) -> Result<Vec<MacAddressEntry>, 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<PortChannelId, Error> {
|
||||
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<u8> = 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(())
|
||||
}
|
||||
}
|
@ -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],
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct BrocadeInfo {
|
||||
os: BrocadeOs,
|
||||
version: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum BrocadeOs {
|
||||
NetworkOperatingSystem,
|
||||
FastIron,
|
||||
Unknown,
|
||||
}
|
||||
|
||||
#[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<BrocadeOptions>,
|
||||
) -> Result<Self, Error> {
|
||||
let ip = ip_addresses
|
||||
.first()
|
||||
.ok_or_else(|| Error::ConfigurationError("No IP addresses provided".to_string()))?;
|
||||
) -> Result<Box<dyn BrocadeClient + Send + Sync>, Error> {
|
||||
let shell = BrocadeShell::init(ip_addresses, port, username, password, options).await?;
|
||||
|
||||
let options = options.unwrap_or_default();
|
||||
|
||||
Ok(Self {
|
||||
ip: *ip,
|
||||
user: UserConfig {
|
||||
username: username.to_string(),
|
||||
password: password.to_string(),
|
||||
},
|
||||
options,
|
||||
let version_info = shell
|
||||
.with_session(ExecutionMode::Regular, |session| {
|
||||
Box::pin(get_brocade_info(session))
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn show_mac_address_table(&self) -> Result<Vec<MacAddressEntry>, Error> {
|
||||
info!("[Brocade] Showing MAC address table...");
|
||||
|
||||
let output = self
|
||||
.run_command("show mac-address", ExecutionMode::Regular)
|
||||
.await?;
|
||||
|
||||
output
|
||||
.lines()
|
||||
.skip(2)
|
||||
.filter_map(|line| self.parse_mac_entry(line))
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub async fn find_available_channel_id(&self) -> Result<u8, Error> {
|
||||
info!("[Brocade] Finding next available channel id...");
|
||||
|
||||
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<u8> = output
|
||||
.lines()
|
||||
.filter_map(|line| {
|
||||
re.captures(line)
|
||||
.and_then(|c| c.get(1))
|
||||
.and_then(|id_match| id_match.as_str().parse().ok())
|
||||
Ok(match version_info.os {
|
||||
BrocadeOs::FastIron => Box::new(FastIronClient {
|
||||
shell,
|
||||
version: version_info,
|
||||
}),
|
||||
BrocadeOs::NetworkOperatingSystem => todo!(),
|
||||
BrocadeOs::Unknown => todo!(),
|
||||
})
|
||||
.collect();
|
||||
}
|
||||
|
||||
let mut next_id: u8 = 1;
|
||||
loop {
|
||||
if !used_ids.contains(&next_id) {
|
||||
break;
|
||||
}
|
||||
next_id += 1;
|
||||
}
|
||||
#[async_trait]
|
||||
pub trait BrocadeClient {
|
||||
async fn version(&self) -> Result<BrocadeInfo, Error>;
|
||||
|
||||
info!("[Brocade] Found channel id: {next_id}");
|
||||
Ok(next_id)
|
||||
}
|
||||
async fn show_mac_address_table(&self) -> Result<Vec<MacAddressEntry>, Error>;
|
||||
|
||||
pub async fn create_port_channel(
|
||||
async fn find_available_channel_id(&self) -> Result<PortChannelId, Error>;
|
||||
|
||||
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<BrocadeInfo, Error> {
|
||||
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<version>[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<version>[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<String> {
|
||||
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<String, Error> {
|
||||
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<String>, 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<russh::Channel<russh::client::Msg>, 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<russh::client::Msg>,
|
||||
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<russh::client::Msg>,
|
||||
command: &str,
|
||||
) -> Result<String, Error> {
|
||||
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<russh::client::Msg>,
|
||||
) -> 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<russh::client::Msg>,
|
||||
) -> 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<russh::client::Msg>,
|
||||
) -> Result<Vec<u8>, 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<russh::client::Msg>,
|
||||
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<Result<MacAddressEntry, Error>> {
|
||||
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<MacAddress, String> {
|
||||
// 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<bool, Self::Error> {
|
||||
Ok(true)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum Error {
|
||||
NetworkError(String),
|
||||
|
330
brocade/src/shell.rs
Normal file
330
brocade/src/shell.rs
Normal file
@ -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<BrocadeOptions>,
|
||||
) -> Result<Self, Error> {
|
||||
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, Error> {
|
||||
BrocadeSession::open(
|
||||
self.ip,
|
||||
self.port,
|
||||
&self.username,
|
||||
&self.password,
|
||||
self.options.clone(),
|
||||
mode,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn with_session<F, R>(&self, mode: ExecutionMode, callback: F) -> Result<R, Error>
|
||||
where
|
||||
F: FnOnce(
|
||||
&mut BrocadeSession,
|
||||
) -> std::pin::Pin<
|
||||
Box<dyn std::future::Future<Output = Result<R, Error>> + 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<String, Error> {
|
||||
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<String>,
|
||||
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<russh::client::Msg>,
|
||||
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<Self, Error> {
|
||||
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<String, Error> {
|
||||
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<String>) -> Result<(), Error> {
|
||||
for command in commands {
|
||||
self.run_command(&command).await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn collect_command_output(&mut self) -> Result<Vec<u8>, 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<russh::client::Msg>,
|
||||
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<russh::client::Msg>,
|
||||
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}"
|
||||
)))
|
||||
}
|
||||
}
|
113
brocade/src/ssh.rs
Normal file
113
brocade/src/ssh.rs
Normal file
@ -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<bool, Self::Error> {
|
||||
Ok(true)
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn try_init_client(
|
||||
username: &str,
|
||||
password: &str,
|
||||
ip: &std::net::IpAddr,
|
||||
base_options: BrocadeOptions,
|
||||
) -> Result<BrocadeOptions, Error> {
|
||||
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<russh::client::Handle<Client>, 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)
|
||||
}
|
@ -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<String> = 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<Option<String>, SwitchError> {
|
||||
) -> Result<Option<PortLocation>, SwitchError> {
|
||||
let client = self.get_switch_client().await?;
|
||||
let port = client.find_port(mac_address).await?;
|
||||
Ok(port)
|
||||
|
@ -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<Option<String>, SwitchError>;
|
||||
) -> Result<Option<PortLocation>, 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<Option<String>, SwitchError>;
|
||||
async fn find_port(
|
||||
&self,
|
||||
mac_address: &MacAddress,
|
||||
) -> Result<Option<PortLocation>, SwitchError>;
|
||||
|
||||
async fn configure_port_channel(
|
||||
&self,
|
||||
channel_name: &str,
|
||||
switch_ports: Vec<String>,
|
||||
switch_ports: Vec<PortLocation>,
|
||||
) -> Result<u8, SwitchError>;
|
||||
}
|
||||
|
||||
|
@ -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<dyn BrocadeClient + Send + Sync>,
|
||||
}
|
||||
|
||||
impl BrocadeSwitchClient {
|
||||
@ -17,30 +20,44 @@ impl BrocadeSwitchClient {
|
||||
password: &str,
|
||||
options: Option<BrocadeOptions>,
|
||||
) -> Result<Self, brocade::Error> {
|
||||
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<Option<String>, SwitchError> {
|
||||
async fn find_port(
|
||||
&self,
|
||||
mac_address: &MacAddress,
|
||||
) -> Result<Option<PortLocation>, 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<String>,
|
||||
switch_ports: Vec<PortLocation>,
|
||||
) -> Result<u8, SwitchError> {
|
||||
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}")))?;
|
||||
|
||||
|
@ -71,7 +71,7 @@ impl<T: Topology + Switch> Interpret<T> 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<T: Topology + Switch> Interpret<T> 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<T: Topology + Switch> Interpret<T> 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<Mutex<Vec<String>>>,
|
||||
available_ports: Arc<Mutex<Vec<PortLocation>>>,
|
||||
configured_host_networks: Arc<Mutex<Vec<(Id, HostNetworkConfig)>>>,
|
||||
}
|
||||
|
||||
@ -318,7 +318,7 @@ mod tests {
|
||||
async fn get_port_for_mac_address(
|
||||
&self,
|
||||
_mac_address: &MacAddress,
|
||||
) -> Result<Option<String>, SwitchError> {
|
||||
) -> Result<Option<PortLocation>, SwitchError> {
|
||||
let mut ports = self.available_ports.lock().unwrap();
|
||||
if ports.is_empty() {
|
||||
return Ok(None);
|
||||
|
@ -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,
|
||||
}
|
||||
|
@ -1,2 +1,3 @@
|
||||
pub mod id;
|
||||
pub mod net;
|
||||
pub mod switch;
|
||||
|
176
harmony_types/src/switch.rs
Normal file
176
harmony_types/src/switch.rs
Normal file
@ -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: `<Stack>/<Module>/<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<Self, Self::Err> {
|
||||
let parts: Vec<&str> = s.split('/').collect();
|
||||
|
||||
if parts.len() != 3 {
|
||||
return Err(PortParseError::InvalidFormat);
|
||||
}
|
||||
|
||||
let parse_segment = |part: &str| -> Result<u8, Self::Err> {
|
||||
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<Self, PortParseError> {
|
||||
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(_, _)));
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue
Block a user