add option to run brocade commands in dry-run

This commit is contained in:
Ian Letourneau 2025-09-28 12:55:37 -04:00
parent 7b6ac6641a
commit f2f55d98d4
7 changed files with 171 additions and 65 deletions

View File

@ -1,4 +1,5 @@
use std::{ use std::{
borrow::Cow,
fmt::{self, Display}, fmt::{self, Display},
sync::Arc, sync::Arc,
}; };
@ -6,8 +7,11 @@ use std::{
use async_trait::async_trait; use async_trait::async_trait;
use harmony_types::net::{IpAddress, MacAddress}; use harmony_types::net::{IpAddress, MacAddress};
use log::{debug, info}; use log::{debug, info};
use russh::client::{Handle, Handler}; use russh::{
use russh_keys::key; client::{Handle, Handler},
kex::DH_G1_SHA1,
};
use russh_keys::key::{self, SSH_RSA};
use std::str::FromStr; use std::str::FromStr;
#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Clone)] #[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Clone)]
@ -19,6 +23,30 @@ pub struct MacAddressEntry {
pub struct BrocadeClient { pub struct BrocadeClient {
client: Handle<Client>, client: Handle<Client>,
options: BrocadeOptions,
}
#[derive(Default, Clone, Debug)]
pub struct BrocadeOptions {
pub dry_run: bool,
pub ssh: SshOptions,
}
#[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()
},
}
}
} }
impl BrocadeClient { impl BrocadeClient {
@ -26,14 +54,25 @@ impl BrocadeClient {
ip_addresses: &[IpAddress], ip_addresses: &[IpAddress],
username: &str, username: &str,
password: &str, password: &str,
options: Option<BrocadeOptions>,
) -> Result<Self, Error> { ) -> Result<Self, Error> {
let ip = ip_addresses[0]; // FIXME: Find a better way to get master switch IP address if ip_addresses.is_empty() {
return Err(Error::ConfigurationError(
"No IP addresses provided".to_string(),
));
}
let config = russh::client::Config::default(); let ip = ip_addresses[0]; // FIXME: Find a better way to get master switch IP address
let options = options.unwrap_or_default();
let config = russh::client::Config {
preferred: options.ssh.preferred_algorithms.clone(),
..Default::default()
};
let mut client = russh::client::connect(Arc::new(config), (ip, 22), Client {}).await?; let mut client = russh::client::connect(Arc::new(config), (ip, 22), Client {}).await?;
match client.authenticate_password(username, password).await? { match client.authenticate_password(username, password).await? {
true => Ok(Self { client }), true => Ok(Self { client, options }),
false => Err(Error::AuthenticationError( false => Err(Error::AuthenticationError(
"ssh authentication failed".to_string(), "ssh authentication failed".to_string(),
)), )),
@ -41,7 +80,7 @@ impl BrocadeClient {
} }
pub async fn show_mac_address_table(&self) -> Result<Vec<MacAddressEntry>, Error> { pub async fn show_mac_address_table(&self) -> Result<Vec<MacAddressEntry>, Error> {
let output = self.run_command("show mac-address-table").await?; let output = self.run_command("show mac-address").await?;
let mut entries = Vec::new(); let mut entries = Vec::new();
// The Brocade output usually has a header and then one entry per line. // The Brocade output usually has a header and then one entry per line.
@ -104,7 +143,7 @@ impl BrocadeClient {
pub async fn find_available_channel_id(&self) -> Result<u8, Error> { pub async fn find_available_channel_id(&self) -> Result<u8, Error> {
debug!("[Brocade] Finding next available channel id..."); debug!("[Brocade] Finding next available channel id...");
let output = self.run_command("show port-channel summary").await?; let output = self.run_command("show lag").await?;
let mut used_ids = Vec::new(); let mut used_ids = Vec::new();
// Sample output line: "3 Po3(SU) LACP Eth Yes 128/128 active " // Sample output line: "3 Po3(SU) LACP Eth Yes 128/128 active "
@ -121,7 +160,7 @@ impl BrocadeClient {
// Sort the used IDs to find the next available number. // Sort the used IDs to find the next available number.
used_ids.sort(); used_ids.sort();
let mut next_id = 1; let mut next_id = 0;
for &id in &used_ids { for &id in &used_ids {
if id == next_id { if id == next_id {
next_id += 1; next_id += 1;
@ -136,6 +175,11 @@ impl BrocadeClient {
} }
async fn run_command(&self, command: &str) -> Result<String, Error> { async fn run_command(&self, command: &str) -> Result<String, Error> {
if !command.starts_with("show") && self.options.dry_run {
info!("[Brocade] Dry-run mode enabled, skipping command: {command}");
return Ok("".into());
}
debug!("[Brocade] Running command: '{command}'..."); debug!("[Brocade] Running command: '{command}'...");
let mut channel = self.client.channel_open_session().await?; let mut channel = self.client.channel_open_session().await?;
@ -161,9 +205,13 @@ impl BrocadeClient {
))); )));
} }
} }
russh::ChannelMsg::Success russh::ChannelMsg::Eof => {
| russh::ChannelMsg::WindowAdjusted { .. } channel.close().await?;
| russh::ChannelMsg::Eof => {} }
russh::ChannelMsg::Close => {
break;
}
russh::ChannelMsg::Success | russh::ChannelMsg::WindowAdjusted { .. } => {}
_ => { _ => {
return Err(Error::UnexpectedError(format!( return Err(Error::UnexpectedError(format!(
"Russh got unexpected msg {msg:?}" "Russh got unexpected msg {msg:?}"
@ -172,20 +220,25 @@ impl BrocadeClient {
} }
} }
channel.close().await?;
let output = String::from_utf8(output).expect("Output should be UTF-8 compatible"); let output = String::from_utf8(output).expect("Output should be UTF-8 compatible");
debug!("[Brocade] Command output:\n{output}");
Ok(output) Ok(output)
} }
async fn run_commands(&self, commands: Vec<String>) -> Result<(), Error> { async fn run_commands(&self, commands: Vec<String>) -> Result<(), Error> {
let mut channel = self.client.channel_open_session().await?;
// Execute commands sequentially and check for errors immediately. // Execute commands sequentially and check for errors immediately.
for command in commands { for command in commands {
if !command.starts_with("show") && self.options.dry_run {
info!("[Brocade] Dry-run mode enabled, skipping command: {command}");
continue;
}
debug!("[Brocade] Running command: '{command}'..."); debug!("[Brocade] Running command: '{command}'...");
let mut channel = self.client.channel_open_session().await?;
let mut output = Vec::new(); let mut output = Vec::new();
let mut close_received = false;
channel.exec(true, command.as_str()).await?; channel.exec(true, command.as_str()).await?;
loop { loop {
@ -206,12 +259,30 @@ impl BrocadeClient {
))); )));
} }
} }
_ => {} // Ignore other messages like success or EOF for now. russh::ChannelMsg::Eof => {
channel.close().await?;
}
russh::ChannelMsg::Close => {
close_received = true;
break;
}
russh::ChannelMsg::Success | russh::ChannelMsg::WindowAdjusted { .. } => {}
_ => {
return Err(Error::UnexpectedError(format!(
"Russh got unexpected msg {msg:?}"
)));
}
} }
} }
if !close_received {
return Err(Error::UnexpectedError(format!(
"Channel closed without receiving a final CLOSE message for command: {}",
command
)));
}
} }
channel.close().await?;
Ok(()) Ok(())
} }
} }

View File

@ -12,11 +12,11 @@ pub type FirewallGroup = Vec<PhysicalHost>;
pub struct PhysicalHost { pub struct PhysicalHost {
pub id: Id, pub id: Id,
pub category: HostCategory, pub category: HostCategory,
pub network: Vec<NetworkInterface>, pub network: Vec<NetworkInterface>, // FIXME: Don't use harmony_inventory_agent::NetworkInterface
pub storage: Vec<StorageDrive>, pub storage: Vec<StorageDrive>, // FIXME: Don't use harmony_inventory_agent::StorageDrive
pub labels: Vec<Label>, pub labels: Vec<Label>,
pub memory_modules: Vec<MemoryModule>, pub memory_modules: Vec<MemoryModule>, // FIXME: Don't use harmony_inventory_agent::MemoryModule
pub cpus: Vec<CPU>, pub cpus: Vec<CPU>, // FIXME: Don't use harmony_inventory_agent::CPU
} }
impl PhysicalHost { impl PhysicalHost {

View File

@ -1,4 +1,5 @@
use async_trait::async_trait; use async_trait::async_trait;
use brocade::BrocadeOptions;
use harmony_macros::ip; use harmony_macros::ip;
use harmony_secret::SecretManager; use harmony_secret::SecretManager;
use harmony_types::net::MacAddress; use harmony_types::net::MacAddress;
@ -305,9 +306,14 @@ impl HAClusterTopology {
// FIXME: We assume Brocade switches // FIXME: We assume Brocade switches
let switches: Vec<IpAddr> = self.switch.iter().map(|s| s.ip).collect(); let switches: Vec<IpAddr> = self.switch.iter().map(|s| s.ip).collect();
let client = BrocadeSwitchClient::init(&switches, &auth.username, &auth.password) let brocade_options = Some(BrocadeOptions {
.await dry_run: *crate::config::DRY_RUN,
.map_err(|e| SwitchError::new(format!("Failed to connect to switch: {e}")))?; ..Default::default()
});
let client =
BrocadeSwitchClient::init(&switches, &auth.username, &auth.password, brocade_options)
.await
.map_err(|e| SwitchError::new(format!("Failed to connect to switch: {e}")))?;
Ok(Box::new(client)) Ok(Box::new(client))
} }
@ -506,14 +512,13 @@ impl HttpServer for HAClusterTopology {
#[async_trait] #[async_trait]
impl Switch for HAClusterTopology { impl Switch for HAClusterTopology {
async fn get_port_for_mac_address(&self, mac_address: &MacAddress) -> Option<String> { async fn get_port_for_mac_address(
let client = self.get_switch_client().await; &self,
mac_address: &MacAddress,
let Ok(client) = client else { ) -> Result<Option<String>, SwitchError> {
return None; let client = self.get_switch_client().await?;
}; let port = client.find_port(mac_address).await?;
Ok(port)
client.find_port(mac_address).await
} }
async fn configure_host_network( async fn configure_host_network(

View File

@ -175,7 +175,10 @@ impl FromStr for DnsRecordType {
#[async_trait] #[async_trait]
pub trait Switch: Send + Sync { pub trait Switch: Send + Sync {
async fn get_port_for_mac_address(&self, mac_address: &MacAddress) -> Option<String>; async fn get_port_for_mac_address(
&self,
mac_address: &MacAddress,
) -> Result<Option<String>, SwitchError>;
async fn configure_host_network( async fn configure_host_network(
&self, &self,
@ -218,7 +221,7 @@ impl Error for SwitchError {}
#[async_trait] #[async_trait]
pub trait SwitchClient: Send + Sync { pub trait SwitchClient: Send + Sync {
async fn find_port(&self, mac_address: &MacAddress) -> Option<String>; async fn find_port(&self, mac_address: &MacAddress) -> Result<Option<String>, SwitchError>;
async fn configure_port_channel(&self, switch_ports: Vec<String>) -> Result<u8, SwitchError>; async fn configure_port_channel(&self, switch_ports: Vec<String>) -> Result<u8, SwitchError>;
} }

View File

@ -1,5 +1,5 @@
use async_trait::async_trait; use async_trait::async_trait;
use brocade::BrocadeClient; use brocade::{BrocadeClient, BrocadeOptions};
use harmony_secret::Secret; use harmony_secret::Secret;
use harmony_types::net::{IpAddress, MacAddress}; use harmony_types::net::{IpAddress, MacAddress};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
@ -15,23 +15,26 @@ impl BrocadeSwitchClient {
ip_addresses: &[IpAddress], ip_addresses: &[IpAddress],
username: &str, username: &str,
password: &str, password: &str,
options: Option<BrocadeOptions>,
) -> Result<Self, brocade::Error> { ) -> Result<Self, brocade::Error> {
let brocade = BrocadeClient::init(ip_addresses, username, password).await?; let brocade = BrocadeClient::init(ip_addresses, username, password, options).await?;
Ok(Self { brocade }) Ok(Self { brocade })
} }
} }
#[async_trait] #[async_trait]
impl SwitchClient for BrocadeSwitchClient { impl SwitchClient for BrocadeSwitchClient {
async fn find_port(&self, mac_address: &MacAddress) -> Option<String> { async fn find_port(&self, mac_address: &MacAddress) -> Result<Option<String>, SwitchError> {
let Ok(table) = self.brocade.show_mac_address_table().await else { let table = self
return None; .brocade
}; .show_mac_address_table()
.await
.map_err(|e| SwitchError::new(format!("Failed to get mac address table: {e}")))?;
table Ok(table
.iter() .iter()
.find(|entry| entry.mac_address == *mac_address) .find(|entry| entry.mac_address == *mac_address)
.map(|entry| entry.port_name.clone()) .map(|entry| entry.port_name.clone()))
} }
async fn configure_port_channel(&self, switch_ports: Vec<String>) -> Result<u8, SwitchError> { async fn configure_port_channel(&self, switch_ports: Vec<String>) -> Result<u8, SwitchError> {

View File

@ -1,5 +1,6 @@
use async_trait::async_trait; use async_trait::async_trait;
use harmony_types::id::Id; use harmony_types::id::Id;
use log::{debug, info, warn};
use serde::Serialize; use serde::Serialize;
use crate::{ use crate::{
@ -56,31 +57,52 @@ impl<T: Topology + Switch> Interpret<T> for HostNetworkConfigurationInterpret {
_inventory: &Inventory, _inventory: &Inventory,
topology: &T, topology: &T,
) -> Result<Outcome, InterpretError> { ) -> Result<Outcome, InterpretError> {
info!(
"Started network configuration for {} host(s)...",
self.score.hosts.len()
);
let mut configured_host_count = 0;
for host in &self.score.hosts { for host in &self.score.hosts {
let mut switch_ports = vec![]; let mut switch_ports = vec![];
for network_interface in &host.network { for network_interface in &host.network {
let mac_address = network_interface.mac_address; let mac_address = network_interface.mac_address;
if let Some(port_name) = topology.get_port_for_mac_address(&mac_address).await { match topology.get_port_for_mac_address(&mac_address).await {
switch_ports.push(SwitchPort { Ok(Some(port_name)) => {
interface: NetworkInterface { switch_ports.push(SwitchPort {
name: network_interface.name.clone(), interface: NetworkInterface {
mac_address, name: network_interface.name.clone(),
speed_mbps: network_interface.speed_mbps, mac_address,
mtu: network_interface.mtu, speed_mbps: network_interface.speed_mbps,
}, mtu: network_interface.mtu,
port_name, },
}); port_name,
});
}
Ok(None) => debug!("No port found for host '{}', skipping", host.id),
Err(e) => warn!("Failed to get port for host '{}': {}", host.id, e),
} }
} }
let _ = topology if !switch_ports.is_empty() {
.configure_host_network(host, HostNetworkConfig { switch_ports }) configured_host_count += 1;
.await; let _ = topology
.configure_host_network(host, HostNetworkConfig { switch_ports })
.await;
}
} }
Ok(Outcome::success("".into())) if configured_host_count > 0 {
Ok(Outcome::success(format!(
"Configured {configured_host_count}/{} host(s)",
self.score.hosts.len()
)))
} else {
Ok(Outcome::noop("No hosts configured".into()))
}
} }
} }
@ -221,12 +243,7 @@ mod tests {
let _ = score.interpret(&Inventory::empty(), &topology).await; let _ = score.interpret(&Inventory::empty(), &topology).await;
let configured_host_networks = topology.configured_host_networks.lock().unwrap(); let configured_host_networks = topology.configured_host_networks.lock().unwrap();
assert_that!(*configured_host_networks).contains_exactly(vec![( assert_that!(*configured_host_networks).is_empty();
HOST_ID.clone(),
HostNetworkConfig {
switch_ports: vec![],
},
)]);
} }
fn given_score(hosts: Vec<PhysicalHost>) -> HostNetworkConfigurationScore { fn given_score(hosts: Vec<PhysicalHost>) -> HostNetworkConfigurationScore {
@ -297,12 +314,15 @@ mod tests {
#[async_trait] #[async_trait]
impl Switch for TopologyWithSwitch { impl Switch for TopologyWithSwitch {
async fn get_port_for_mac_address(&self, _mac_address: &MacAddress) -> Option<String> { async fn get_port_for_mac_address(
&self,
_mac_address: &MacAddress,
) -> Result<Option<String>, SwitchError> {
let mut ports = self.available_ports.lock().unwrap(); let mut ports = self.available_ports.lock().unwrap();
if ports.is_empty() { if ports.is_empty() {
return None; return Ok(None);
} }
Some(ports.remove(0)) Ok(Some(ports.remove(0)))
} }
async fn configure_host_network( async fn configure_host_network(

View File

@ -54,6 +54,9 @@ struct DeployArgs {
#[arg(long = "profile", short = 'p', default_value = "dev")] #[arg(long = "profile", short = 'p', default_value = "dev")]
harmony_profile: HarmonyProfile, harmony_profile: HarmonyProfile,
#[arg(long = "dry-run", short = 'd', default_value = "false")]
dry_run: bool,
} }
#[derive(Args, Clone, Debug)] #[derive(Args, Clone, Debug)]
@ -178,6 +181,7 @@ async fn main() {
command command
.env("HARMONY_USE_LOCAL_K3D", format!("{use_local_k3d}")) .env("HARMONY_USE_LOCAL_K3D", format!("{use_local_k3d}"))
.env("HARMONY_PROFILE", format!("{}", args.harmony_profile)) .env("HARMONY_PROFILE", format!("{}", args.harmony_profile))
.env("HARMONY_DRY_RUN", format!("{}", args.dry_run))
.arg("-y") .arg("-y")
.arg("-a"); .arg("-a");