diff --git a/Cargo.lock b/Cargo.lock index c3a4e75..39d1449 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -680,11 +680,13 @@ version = "0.1.0" dependencies = [ "async-trait", "env_logger", + "harmony_secret", "harmony_types", "log", "regex", "russh", "russh-keys", + "serde", "tokio", ] @@ -2363,6 +2365,7 @@ dependencies = [ "once_cell", "opnsense-config", "opnsense-config-xml", + "option-ext", "pretty_assertions", "reqwest 0.11.27", "russh", diff --git a/brocade/Cargo.toml b/brocade/Cargo.toml index 01dc320..89c4fb8 100644 --- a/brocade/Cargo.toml +++ b/brocade/Cargo.toml @@ -14,3 +14,5 @@ tokio.workspace = true log.workspace = true env_logger.workspace = true regex = "1.11.3" +harmony_secret = { path = "../harmony_secret" } +serde.workspace = true diff --git a/brocade/examples/main.rs b/brocade/examples/main.rs index fac97ee..58f6c59 100644 --- a/brocade/examples/main.rs +++ b/brocade/examples/main.rs @@ -1,22 +1,33 @@ use std::net::{IpAddr, Ipv4Addr}; use brocade::BrocadeOptions; -use harmony_types::switch::PortLocation; +use harmony_secret::{Secret, SecretManager}; +use serde::{Deserialize, Serialize}; + +#[derive(Secret, Clone, Debug, Serialize, Deserialize)] +struct BrocadeSwitchAuth { + username: String, + password: String, +} #[tokio::main] async fn main() { env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init(); // 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 ip = IpAddr::V4(Ipv4Addr::new(192, 168, 4, 11)); // brocade @ st + let ip = IpAddr::V4(Ipv4Addr::new(192, 168, 55, 101)); // brocade @ sto1 + // let ip = IpAddr::V4(Ipv4Addr::new(192, 168, 4, 11)); // brocade @ st let switch_addresses = vec![ip]; + let config = SecretManager::get_or_prompt::() + .await + .unwrap(); + let brocade = brocade::init( &switch_addresses, 22, - "admin", - "password", + &config.username, + &config.password, Some(BrocadeOptions { dry_run: true, ..Default::default() diff --git a/brocade/src/fast_iron.rs b/brocade/src/fast_iron.rs index 9cb57e4..d33bd93 100644 --- a/brocade/src/fast_iron.rs +++ b/brocade/src/fast_iron.rs @@ -1,7 +1,7 @@ use super::BrocadeClient; use crate::{ BrocadeInfo, Error, ExecutionMode, InterSwitchLink, InterfaceInfo, MacAddressEntry, - PortChannelId, parse_brocade_mac_address, shell::BrocadeShell, + PortChannelId, PortOperatingMode, parse_brocade_mac_address, shell::BrocadeShell, }; use async_trait::async_trait; @@ -105,11 +105,6 @@ impl BrocadeClient for FastIronClient { Ok(self.version.clone()) } - async fn setup(&self) -> Result<(), Error> { - // Nothing to do, for now - Ok(()) - } - async fn get_mac_address_table(&self) -> Result, Error> { info!("[Brocade] Showing MAC address table..."); @@ -142,6 +137,13 @@ impl BrocadeClient for FastIronClient { todo!() } + async fn configure_interfaces( + &self, + _interfaces: Vec<(String, PortOperatingMode)>, + ) -> Result<(), Error> { + todo!() + } + async fn find_available_channel_id(&self) -> Result { info!("[Brocade] Finding next available channel id..."); diff --git a/brocade/src/lib.rs b/brocade/src/lib.rs index 9311df0..3822abd 100644 --- a/brocade/src/lib.rs +++ b/brocade/src/lib.rs @@ -173,18 +173,6 @@ pub trait BrocadeClient { /// A `BrocadeInfo` structure containing parsed OS type and version string. async fn version(&self) -> Result; - /// Executes essential, idempotent, one-time initial configuration steps. - /// - /// This is an opiniated procedure that setups a switch to provide high availability - /// capabilities as decided by the NationTech team. - /// - /// This includes tasks like ensuring necessary feature licenses are active, - /// enabling switchport for all interfaces except the ones intended for Fabric Networking, etc. - /// - /// The implementation must ensure the operation is **idempotent** (safe to run multiple times) - /// and that it doesn't break existing configurations. - async fn setup(&self) -> Result<(), Error>; - /// Retrieves the dynamically learned MAC address table from the switch. /// /// This is crucial for discovering where specific network endpoints (MAC addresses) @@ -215,6 +203,12 @@ pub trait BrocadeClient { /// A vector of `InterfaceInfo` structures. async fn get_interfaces(&self) -> Result, Error>; + /// Configures a set of interfaces to be operated with a specified mode (access ports, ISL, etc.). + async fn configure_interfaces( + &self, + interfaces: Vec<(String, PortOperatingMode)>, + ) -> Result<(), Error>; + /// Scans the existing configuration to find the next available (unused) /// Port-Channel ID (`lag` or `trunk`) for assignment. /// diff --git a/brocade/src/network_operating_system.rs b/brocade/src/network_operating_system.rs index 883bba9..6a3c8fb 100644 --- a/brocade/src/network_operating_system.rs +++ b/brocade/src/network_operating_system.rs @@ -5,9 +5,9 @@ use harmony_types::switch::{PortDeclaration, PortLocation}; use log::debug; use crate::{ - BrocadeClient, BrocadeInfo, Error, InterSwitchLink, InterfaceInfo, InterfaceStatus, - InterfaceType, MacAddressEntry, PortChannelId, PortOperatingMode, parse_brocade_mac_address, - shell::BrocadeShell, + BrocadeClient, BrocadeInfo, Error, ExecutionMode, InterSwitchLink, InterfaceInfo, + InterfaceStatus, InterfaceType, MacAddressEntry, PortChannelId, PortOperatingMode, + parse_brocade_mac_address, shell::BrocadeShell, }; pub struct NetworkOperatingSystemClient { @@ -117,17 +117,10 @@ impl BrocadeClient for NetworkOperatingSystemClient { Ok(self.version.clone()) } - async fn setup(&self) -> Result<(), Error> { - let links = self.get_stack_topology().await?; - let interfaces = self.get_interfaces().await?; - - todo!() - } - async fn get_mac_address_table(&self) -> Result, Error> { let output = self .shell - .run_command("show mac-address-table", crate::ExecutionMode::Regular) + .run_command("show mac-address-table", ExecutionMode::Regular) .await?; output @@ -140,7 +133,7 @@ impl BrocadeClient for NetworkOperatingSystemClient { async fn get_stack_topology(&self) -> Result, Error> { let output = self .shell - .run_command("show fabric isl", crate::ExecutionMode::Regular) + .run_command("show fabric isl", ExecutionMode::Regular) .await?; output @@ -155,7 +148,7 @@ impl BrocadeClient for NetworkOperatingSystemClient { .shell .run_command( "show interface status rbridge-id all", - crate::ExecutionMode::Regular, + ExecutionMode::Regular, ) .await?; @@ -166,20 +159,64 @@ impl BrocadeClient for NetworkOperatingSystemClient { .collect() } + async fn configure_interfaces( + &self, + interfaces: Vec<(String, PortOperatingMode)>, + ) -> Result<(), Error> { + let mut commands = vec!["configure terminal".to_string()]; + + for interface in interfaces { + commands.push(format!("interface {}", interface.0)); + + match interface.1 { + PortOperatingMode::Fabric => { + commands.push("fabric isl enable".into()); + commands.push("fabric trunk enable".into()); + } + PortOperatingMode::Trunk => { + commands.push("switchport".into()); + commands.push("switchport mode trunk".into()); + commands.push("no spanning-tree shutdown".into()); + commands.push("no fabric isl enable".into()); + commands.push("no fabric trunk enable".into()); + } + PortOperatingMode::Access => { + commands.push("switchport".into()); + commands.push("switchport mode access".into()); + commands.push("switchport access vlan 1".into()); + commands.push("no spanning-tree shutdown".into()); + commands.push("no fabric isl enable".into()); + commands.push("no fabric trunk enable".into()); + } + } + + commands.push("no shutdown".into()); + commands.push("exit".into()); + } + + commands.push("write memory".into()); + + self.shell + .run_commands(commands, ExecutionMode::Regular) + .await?; + + Ok(()) + } + async fn find_available_channel_id(&self) -> Result { todo!() } async fn create_port_channel( &self, - channel_id: PortChannelId, - channel_name: &str, - ports: &[PortLocation], + _channel_id: PortChannelId, + _channel_name: &str, + _ports: &[PortLocation], ) -> Result<(), Error> { todo!() } - async fn clear_port_channel(&self, channel_name: &str) -> Result<(), Error> { + async fn clear_port_channel(&self, _channel_name: &str) -> Result<(), Error> { todo!() } } diff --git a/harmony/Cargo.toml b/harmony/Cargo.toml index 3633983..634cbe9 100644 --- a/harmony/Cargo.toml +++ b/harmony/Cargo.toml @@ -78,6 +78,7 @@ askama.workspace = true sqlx.workspace = true inquire.workspace = true brocade = { path = "../brocade" } +option-ext = "0.2.0" [dev-dependencies] pretty_assertions.workspace = true diff --git a/harmony/src/domain/topology/network.rs b/harmony/src/domain/topology/network.rs index e31ce23..99db03a 100644 --- a/harmony/src/domain/topology/network.rs +++ b/harmony/src/domain/topology/network.rs @@ -226,6 +226,16 @@ impl Error for SwitchError {} #[async_trait] pub trait SwitchClient: Send + Sync { + /// Executes essential, idempotent, one-time initial configuration steps. + /// + /// This is an opiniated procedure that setups a switch to provide high availability + /// capabilities as decided by the NationTech team. + /// + /// This includes tasks like enabling switchport for all interfaces + /// except the ones intended for Fabric Networking, etc. + /// + /// The implementation must ensure the operation is **idempotent** (safe to run multiple times) + /// and that it doesn't break existing configurations. async fn setup(&self) -> Result<(), SwitchError>; async fn find_port( diff --git a/harmony/src/infra/brocade.rs b/harmony/src/infra/brocade.rs index 7bbf8bc..f721328 100644 --- a/harmony/src/infra/brocade.rs +++ b/harmony/src/infra/brocade.rs @@ -1,10 +1,11 @@ use async_trait::async_trait; -use brocade::{BrocadeClient, BrocadeOptions}; +use brocade::{BrocadeClient, BrocadeOptions, InterSwitchLink, InterfaceStatus, PortOperatingMode}; use harmony_secret::Secret; use harmony_types::{ net::{IpAddress, MacAddress}, switch::{PortDeclaration, PortLocation}, }; +use option_ext::OptionExt; use serde::{Deserialize, Serialize}; use crate::topology::{SwitchClient, SwitchError}; @@ -28,10 +29,42 @@ impl BrocadeSwitchClient { #[async_trait] impl SwitchClient for BrocadeSwitchClient { async fn setup(&self) -> Result<(), SwitchError> { - self.brocade - .setup() + let stack_topology = self + .brocade + .get_stack_topology() .await - .map_err(|e| SwitchError::new(e.to_string())) + .map_err(|e| SwitchError::new(e.to_string()))?; + + let interfaces = self + .brocade + .get_interfaces() + .await + .map_err(|e| SwitchError::new(e.to_string()))?; + + let interfaces: Vec<(String, PortOperatingMode)> = interfaces + .into_iter() + .filter(|interface| { + interface.operating_mode.is_none() && interface.status == InterfaceStatus::Connected + }) + .filter(|interface| { + !stack_topology.iter().any(|link: &InterSwitchLink| { + link.local_port == interface.port_location + || link.remote_port.contains(&interface.port_location) + }) + }) + .map(|interface| (interface.name.clone(), PortOperatingMode::Access)) + .collect(); + + if interfaces.is_empty() { + return Ok(()); + } + + self.brocade + .configure_interfaces(interfaces) + .await + .map_err(|e| SwitchError::new(e.to_string()))?; + + Ok(()) } async fn find_port( @@ -86,3 +119,267 @@ pub struct BrocadeSwitchAuth { pub username: String, pub password: String, } + +#[cfg(test)] +mod tests { + use std::sync::{Arc, Mutex}; + + use assertor::*; + use async_trait::async_trait; + use brocade::{ + BrocadeClient, BrocadeInfo, Error, InterSwitchLink, InterfaceInfo, InterfaceStatus, + InterfaceType, MacAddressEntry, PortChannelId, PortOperatingMode, + }; + use harmony_types::switch::PortLocation; + + use crate::{infra::brocade::BrocadeSwitchClient, topology::SwitchClient}; + + #[tokio::test] + async fn setup_should_configure_ethernet_interfaces_as_access_ports() { + let first_interface = given_interface() + .with_port_location(PortLocation(1, 0, 1)) + .build(); + let second_interface = given_interface() + .with_port_location(PortLocation(1, 0, 4)) + .build(); + let brocade = Box::new(FakeBrocadeClient::new( + vec![], + vec![first_interface.clone(), second_interface.clone()], + )); + let client = BrocadeSwitchClient { + brocade: brocade.clone(), + }; + + client.setup().await.unwrap(); + + let configured_interfaces = brocade.configured_interfaces.lock().unwrap(); + assert_that!(*configured_interfaces).contains_exactly(vec![ + (first_interface.name.clone(), PortOperatingMode::Access), + (second_interface.name.clone(), PortOperatingMode::Access), + ]); + } + + #[tokio::test] + async fn setup_with_an_already_configured_interface_should_skip_configuration() { + let brocade = Box::new(FakeBrocadeClient::new( + vec![], + vec![ + given_interface() + .with_operating_mode(Some(PortOperatingMode::Access)) + .build(), + ], + )); + let client = BrocadeSwitchClient { + brocade: brocade.clone(), + }; + + client.setup().await.unwrap(); + + let configured_interfaces = brocade.configured_interfaces.lock().unwrap(); + assert_that!(*configured_interfaces).is_empty(); + } + + #[tokio::test] + async fn setup_with_a_disconnected_interface_should_skip_configuration() { + let brocade = Box::new(FakeBrocadeClient::new( + vec![], + vec![ + given_interface() + .with_status(InterfaceStatus::SfpAbsent) + .build(), + given_interface() + .with_status(InterfaceStatus::NotConnected) + .build(), + ], + )); + let client = BrocadeSwitchClient { + brocade: brocade.clone(), + }; + + client.setup().await.unwrap(); + + let configured_interfaces = brocade.configured_interfaces.lock().unwrap(); + assert_that!(*configured_interfaces).is_empty(); + } + + #[tokio::test] + async fn setup_with_inter_switch_links_should_not_configure_interfaces_used_to_form_stack() { + let brocade = Box::new(FakeBrocadeClient::new( + vec![ + given_inter_switch_link() + .between(PortLocation(1, 0, 1), PortLocation(2, 0, 1)) + .build(), + given_inter_switch_link() + .between(PortLocation(2, 0, 2), PortLocation(3, 0, 1)) + .build(), + ], + vec![ + given_interface() + .with_port_location(PortLocation(1, 0, 1)) + .build(), + given_interface() + .with_port_location(PortLocation(2, 0, 1)) + .build(), + given_interface() + .with_port_location(PortLocation(3, 0, 1)) + .build(), + ], + )); + let client = BrocadeSwitchClient { + brocade: brocade.clone(), + }; + + client.setup().await.unwrap(); + + let configured_interfaces = brocade.configured_interfaces.lock().unwrap(); + assert_that!(*configured_interfaces).is_empty(); + } + + #[derive(Clone)] + struct FakeBrocadeClient { + stack_topology: Vec, + interfaces: Vec, + configured_interfaces: Arc>>, + } + + #[async_trait] + impl BrocadeClient for FakeBrocadeClient { + async fn version(&self) -> Result { + todo!() + } + + async fn get_mac_address_table(&self) -> Result, Error> { + todo!() + } + + async fn get_stack_topology(&self) -> Result, Error> { + Ok(self.stack_topology.clone()) + } + + async fn get_interfaces(&self) -> Result, Error> { + Ok(self.interfaces.clone()) + } + + async fn configure_interfaces( + &self, + interfaces: Vec<(String, PortOperatingMode)>, + ) -> Result<(), Error> { + let mut configured_interfaces = self.configured_interfaces.lock().unwrap(); + *configured_interfaces = interfaces; + + Ok(()) + } + + async fn find_available_channel_id(&self) -> Result { + todo!() + } + + async fn create_port_channel( + &self, + _channel_id: PortChannelId, + _channel_name: &str, + _ports: &[PortLocation], + ) -> Result<(), Error> { + todo!() + } + + async fn clear_port_channel(&self, _channel_name: &str) -> Result<(), Error> { + todo!() + } + } + + impl FakeBrocadeClient { + fn new(stack_topology: Vec, interfaces: Vec) -> Self { + Self { + stack_topology, + interfaces, + configured_interfaces: Arc::new(Mutex::new(vec![])), + } + } + } + + struct InterfaceInfoBuilder { + port_location: Option, + interface_type: Option, + operating_mode: Option, + status: Option, + } + + impl InterfaceInfoBuilder { + fn build(&self) -> InterfaceInfo { + let interface_type = self + .interface_type + .clone() + .unwrap_or(InterfaceType::Ethernet("TenGigabitEthernet".into())); + let port_location = self.port_location.clone().unwrap_or(PortLocation(1, 0, 1)); + let name = format!("{interface_type} {port_location}"); + let status = self.status.clone().unwrap_or(InterfaceStatus::Connected); + + InterfaceInfo { + name, + port_location, + interface_type, + operating_mode: self.operating_mode.clone(), + status, + } + } + + fn with_port_location(self, port_location: PortLocation) -> Self { + Self { + port_location: Some(port_location), + ..self + } + } + + fn with_operating_mode(self, operating_mode: Option) -> Self { + Self { + operating_mode, + ..self + } + } + + fn with_status(self, status: InterfaceStatus) -> Self { + Self { + status: Some(status), + ..self + } + } + } + + struct InterSwitchLinkBuilder { + link: Option<(PortLocation, PortLocation)>, + } + + impl InterSwitchLinkBuilder { + fn build(&self) -> InterSwitchLink { + let link = self + .link + .clone() + .unwrap_or((PortLocation(1, 0, 1), PortLocation(2, 0, 1))); + + InterSwitchLink { + local_port: link.0, + remote_port: Some(link.1), + } + } + + fn between(self, local_port: PortLocation, remote_port: PortLocation) -> Self { + Self { + link: Some((local_port, remote_port)), + } + } + } + + fn given_interface() -> InterfaceInfoBuilder { + InterfaceInfoBuilder { + port_location: None, + interface_type: None, + operating_mode: None, + status: None, + } + } + + fn given_inter_switch_link() -> InterSwitchLinkBuilder { + InterSwitchLinkBuilder { link: None } + } +}