From bf4f300383b6086b94646ecab0ef7f1901aa9a6f Mon Sep 17 00:00:00 2001 From: Sylvain Tremblay Date: Tue, 21 Apr 2026 10:13:58 -0400 Subject: [PATCH 1/6] feat(discovery): capture bond, blacklist and bond-mode intent per host Extend DiscoverHostForRoleScore with three new interactive prompts after the installation-disk selection: - "Configure a network bond?" (only when host has >= 2 NICs), followed by a multi-select of bond members (min 2) and a bond-mode picker (LACP / active-backup / balance-rr / balance-xor / broadcast / balance-tlb / balance-alb). - "Blacklist any remaining interface?", with candidates limited to NICs not already claimed by the bond. The answers are persisted as a JSON-encoded NetworkConfig on a new host_role_mapping.network_config column. HostConfig now exposes network_config alongside installation_device so downstream scores can honor the user's intent. Also adds a new harmony_host_discovery example that discovers a single host on 192.168.40.0/24:25000. --- ...cd256d74f572629b8c0764782066e705c50c.json} | 6 +- ...52a9193dcb09a4b917f0fde9f39058e0f276.json} | 10 +- ...090c94a222115c543231f2140cba27bd0f067.json | 2 +- Cargo.lock | 13 ++ examples/harmony_host_discovery/Cargo.toml | 15 ++ examples/harmony_host_discovery/env.sh | 4 + examples/harmony_host_discovery/src/main.rs | 27 +++ harmony/src/domain/inventory/repository.rs | 6 +- harmony/src/domain/topology/host_binding.rs | 59 +++++- harmony/src/infra/inventory/sqlite.rs | 21 ++- harmony/src/modules/inventory/discovery.rs | 170 +++++++++++++++++- ...dd_network_config_to_host_role_mapping.sql | 3 + 12 files changed, 321 insertions(+), 15 deletions(-) rename .sqlx/{query-6fcc29cfdbdf3b2cee94a4844e227f09b245dd8f079832a9a7b774151cb03af6.json => query-165b944d13c8f7810b4e3ef891e5cd256d74f572629b8c0764782066e705c50c.json} (50%) rename .sqlx/{query-24f719d57144ecf4daa55f0aa5836c165872d70164401c0388e8d625f1b72d7b.json => query-43cfa7b6dda8b9745ef74eb45f3f52a9193dcb09a4b917f0fde9f39058e0f276.json} (55%) create mode 100644 examples/harmony_host_discovery/Cargo.toml create mode 100644 examples/harmony_host_discovery/env.sh create mode 100644 examples/harmony_host_discovery/src/main.rs create mode 100644 migrations/20260421000000_add_network_config_to_host_role_mapping.sql diff --git a/.sqlx/query-6fcc29cfdbdf3b2cee94a4844e227f09b245dd8f079832a9a7b774151cb03af6.json b/.sqlx/query-165b944d13c8f7810b4e3ef891e5cd256d74f572629b8c0764782066e705c50c.json similarity index 50% rename from .sqlx/query-6fcc29cfdbdf3b2cee94a4844e227f09b245dd8f079832a9a7b774151cb03af6.json rename to .sqlx/query-165b944d13c8f7810b4e3ef891e5cd256d74f572629b8c0764782066e705c50c.json index d3f774b8..deacd686 100644 --- a/.sqlx/query-6fcc29cfdbdf3b2cee94a4844e227f09b245dd8f079832a9a7b774151cb03af6.json +++ b/.sqlx/query-165b944d13c8f7810b4e3ef891e5cd256d74f572629b8c0764782066e705c50c.json @@ -1,12 +1,12 @@ { "db_name": "SQLite", - "query": "\n INSERT INTO host_role_mapping (host_id, role, installation_device)\n VALUES (?, ?, ?)\n ", + "query": "\n INSERT INTO host_role_mapping (host_id, role, installation_device, network_config)\n VALUES (?, ?, ?, ?)\n ", "describe": { "columns": [], "parameters": { - "Right": 3 + "Right": 4 }, "nullable": [] }, - "hash": "6fcc29cfdbdf3b2cee94a4844e227f09b245dd8f079832a9a7b774151cb03af6" + "hash": "165b944d13c8f7810b4e3ef891e5cd256d74f572629b8c0764782066e705c50c" } diff --git a/.sqlx/query-24f719d57144ecf4daa55f0aa5836c165872d70164401c0388e8d625f1b72d7b.json b/.sqlx/query-43cfa7b6dda8b9745ef74eb45f3f52a9193dcb09a4b917f0fde9f39058e0f276.json similarity index 55% rename from .sqlx/query-24f719d57144ecf4daa55f0aa5836c165872d70164401c0388e8d625f1b72d7b.json rename to .sqlx/query-43cfa7b6dda8b9745ef74eb45f3f52a9193dcb09a4b917f0fde9f39058e0f276.json index 60209751..b899023d 100644 --- a/.sqlx/query-24f719d57144ecf4daa55f0aa5836c165872d70164401c0388e8d625f1b72d7b.json +++ b/.sqlx/query-43cfa7b6dda8b9745ef74eb45f3f52a9193dcb09a4b917f0fde9f39058e0f276.json @@ -1,6 +1,6 @@ { "db_name": "SQLite", - "query": "SELECT host_id, installation_device FROM host_role_mapping WHERE role = ?", + "query": "SELECT host_id, installation_device, network_config FROM host_role_mapping WHERE role = ?", "describe": { "columns": [ { @@ -12,6 +12,11 @@ "name": "installation_device", "ordinal": 1, "type_info": "Text" + }, + { + "name": "network_config", + "ordinal": 2, + "type_info": "Text" } ], "parameters": { @@ -19,8 +24,9 @@ }, "nullable": [ false, + true, true ] }, - "hash": "24f719d57144ecf4daa55f0aa5836c165872d70164401c0388e8d625f1b72d7b" + "hash": "43cfa7b6dda8b9745ef74eb45f3f52a9193dcb09a4b917f0fde9f39058e0f276" } diff --git a/.sqlx/query-8d247918eca10a88b784ee353db090c94a222115c543231f2140cba27bd0f067.json b/.sqlx/query-8d247918eca10a88b784ee353db090c94a222115c543231f2140cba27bd0f067.json index 0b92e37a..ba998bc8 100644 --- a/.sqlx/query-8d247918eca10a88b784ee353db090c94a222115c543231f2140cba27bd0f067.json +++ b/.sqlx/query-8d247918eca10a88b784ee353db090c94a222115c543231f2140cba27bd0f067.json @@ -16,7 +16,7 @@ { "name": "data: Json", "ordinal": 2, - "type_info": "Blob" + "type_info": "Null" } ], "parameters": { diff --git a/Cargo.lock b/Cargo.lock index 007854cc..86a77a4b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3819,6 +3819,19 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "harmony_host_discovery" +version = "0.1.0" +dependencies = [ + "cidr", + "harmony", + "harmony_cli", + "harmony_macros", + "harmony_types", + "tokio", + "url", +] + [[package]] name = "harmony_i18n" version = "0.1.0" diff --git a/examples/harmony_host_discovery/Cargo.toml b/examples/harmony_host_discovery/Cargo.toml new file mode 100644 index 00000000..c043f434 --- /dev/null +++ b/examples/harmony_host_discovery/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "harmony_host_discovery" +edition = "2024" +version.workspace = true +readme.workspace = true +license.workspace = true + +[dependencies] +harmony = { path = "../../harmony" } +harmony_cli = { path = "../../harmony_cli" } +harmony_macros = { path = "../../harmony_macros" } +harmony_types = { path = "../../harmony_types" } +tokio.workspace = true +url.workspace = true +cidr.workspace = true diff --git a/examples/harmony_host_discovery/env.sh b/examples/harmony_host_discovery/env.sh new file mode 100644 index 00000000..0b9da4f6 --- /dev/null +++ b/examples/harmony_host_discovery/env.sh @@ -0,0 +1,4 @@ +export HARMONY_SECRET_NAMESPACE=host-discovery +export HARMONY_SECRET_STORE=file +export HARMONY_DATABASE_URL=sqlite://harmony_host_discovery.sqlite +export RUST_LOG=harmony=debug diff --git a/examples/harmony_host_discovery/src/main.rs b/examples/harmony_host_discovery/src/main.rs new file mode 100644 index 00000000..98140d03 --- /dev/null +++ b/examples/harmony_host_discovery/src/main.rs @@ -0,0 +1,27 @@ +use harmony::{ + inventory::{HostRole, Inventory}, + modules::inventory::{DiscoverHostForRoleScore, HarmonyDiscoveryStrategy}, + topology::LocalhostTopology, +}; +use harmony_macros::cidrv4; + +#[tokio::main] +async fn main() { + let discover_one_host = DiscoverHostForRoleScore { + role: HostRole::Worker, + number_desired_hosts: 1, + discovery_strategy: HarmonyDiscoveryStrategy::SUBNET { + cidr: cidrv4!("192.168.40.0/24"), + port: 25000, + }, + }; + + harmony_cli::run( + Inventory::autoload(), + LocalhostTopology::new(), + vec![Box::new(discover_one_host)], + None, + ) + .await + .unwrap(); +} diff --git a/harmony/src/domain/inventory/repository.rs b/harmony/src/domain/inventory/repository.rs index e6a4eea8..de291528 100644 --- a/harmony/src/domain/inventory/repository.rs +++ b/harmony/src/domain/inventory/repository.rs @@ -1,7 +1,10 @@ use async_trait::async_trait; use crate::{ - hardware::PhysicalHost, interpret::InterpretError, inventory::HostRole, topology::HostConfig, + hardware::PhysicalHost, + interpret::InterpretError, + inventory::HostRole, + topology::{HostConfig, NetworkConfig}, }; /// Errors that can occur within the repository layer. @@ -40,5 +43,6 @@ pub trait InventoryRepository: Send + Sync + 'static { role: &HostRole, host: &PhysicalHost, installation_device: &String, + network_config: &NetworkConfig, ) -> Result<(), RepoError>; } diff --git a/harmony/src/domain/topology/host_binding.rs b/harmony/src/domain/topology/host_binding.rs index 63352762..90186fea 100644 --- a/harmony/src/domain/topology/host_binding.rs +++ b/harmony/src/domain/topology/host_binding.rs @@ -1,5 +1,5 @@ use derive_new::new; -use serde::Serialize; +use serde::{Deserialize, Serialize}; use crate::hardware::PhysicalHost; @@ -20,4 +20,61 @@ pub struct HostBinding { #[derive(Debug, new, Clone, Serialize)] pub struct HostConfig { pub installation_device: Option, + #[new(default)] + pub network_config: NetworkConfig, +} + +/// User-provided networking intent captured at discovery time. +/// +/// Produced by the interactive discovery flow and persisted alongside the role +/// mapping so downstream Scores can act on it (e.g. configuring a bond on the +/// chosen interfaces and avoiding blacklisted ones). +#[derive(Debug, Default, Clone, Serialize, Deserialize)] +pub struct NetworkConfig { + pub bond: Option, + pub blacklisted_interfaces: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BondConfig { + pub interfaces: Vec, + pub mode: BondMode, +} + +/// Linux kernel bonding modes. +/// +/// Names match the `bonding` driver's `mode` parameter. See +/// for +/// detail on each mode's failover and load-balancing behaviour. +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +pub enum BondMode { + /// mode 0 — round-robin across slaves. + BalanceRr, + /// mode 1 — only one slave active at a time; the other(s) take over on failure. + ActiveBackup, + /// mode 2 — XOR-based slave selection by (src MAC ⊕ dst MAC). + BalanceXor, + /// mode 3 — transmit everything on every slave. + Broadcast, + /// mode 4 — IEEE 802.3ad dynamic link aggregation (LACP). Requires switch support. + Lacp, + /// mode 5 — adaptive transmit load balancing; no switch support required. + BalanceTlb, + /// mode 6 — adaptive load balancing (TLB + receive load balancing via ARP negotiation). + BalanceAlb, +} + +impl std::fmt::Display for BondMode { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let s = match self { + BondMode::BalanceRr => "balance-rr (mode 0) — round-robin", + BondMode::ActiveBackup => "active-backup (mode 1) — failover, no switch support needed", + BondMode::BalanceXor => "balance-xor (mode 2) — XOR hash", + BondMode::Broadcast => "broadcast (mode 3) — transmit on all slaves", + BondMode::Lacp => "802.3ad / LACP (mode 4) — dynamic link aggregation", + BondMode::BalanceTlb => "balance-tlb (mode 5) — adaptive transmit load balancing", + BondMode::BalanceAlb => "balance-alb (mode 6) — adaptive load balancing", + }; + f.write_str(s) + } } diff --git a/harmony/src/infra/inventory/sqlite.rs b/harmony/src/infra/inventory/sqlite.rs index 3ce1654f..56c3a4fd 100644 --- a/harmony/src/infra/inventory/sqlite.rs +++ b/harmony/src/infra/inventory/sqlite.rs @@ -1,7 +1,7 @@ use crate::{ hardware::PhysicalHost, inventory::{HostRole, InventoryRepository, RepoError}, - topology::HostConfig, + topology::{HostConfig, NetworkConfig}, }; use async_trait::async_trait; use harmony_types::id::Id; @@ -109,17 +109,21 @@ impl InventoryRepository for SqliteInventoryRepository { role: &HostRole, host: &PhysicalHost, installation_device: &String, + network_config: &NetworkConfig, ) -> Result<(), RepoError> { let host_id = host.id.to_string(); + let network_config_json = serde_json::to_string(network_config) + .map_err(|e| RepoError::Serialization(e.to_string()))?; sqlx::query!( r#" - INSERT INTO host_role_mapping (host_id, role, installation_device) - VALUES (?, ?, ?) + INSERT INTO host_role_mapping (host_id, role, installation_device, network_config) + VALUES (?, ?, ?, ?) "#, host_id, role, - installation_device + installation_device, + network_config_json, ) .execute(&self.pool) .await?; @@ -136,13 +140,14 @@ impl InventoryRepository for SqliteInventoryRepository { struct HostIdRow { host_id: String, installation_device: Option, + network_config: Option, } let role_str = format!("{:?}", role); let host_id_rows = sqlx::query_as!( HostIdRow, - "SELECT host_id, installation_device FROM host_role_mapping WHERE role = ?", + "SELECT host_id, installation_device, network_config FROM host_role_mapping WHERE role = ?", role_str ) .fetch_all(&self.pool) @@ -159,8 +164,14 @@ impl InventoryRepository for SqliteInventoryRepository { ))); } }; + let network_config = match row.network_config.as_deref() { + Some(json) => serde_json::from_str(json) + .map_err(|e| RepoError::Deserialization(e.to_string()))?, + None => NetworkConfig::default(), + }; let host_config = HostConfig { installation_device: row.installation_device, + network_config, }; hosts.push((physical_host, host_config)); } diff --git a/harmony/src/modules/inventory/discovery.rs b/harmony/src/modules/inventory/discovery.rs index bd3f7186..9d037c3e 100644 --- a/harmony/src/modules/inventory/discovery.rs +++ b/harmony/src/modules/inventory/discovery.rs @@ -1,16 +1,18 @@ use async_trait::async_trait; +use harmony_inventory_agent::hwinfo::NetworkInterface; use harmony_types::id::Id; use log::{error, info}; use serde::{Deserialize, Serialize}; use crate::{ data::Version, + hardware::PhysicalHost, infra::inventory::InventoryRepositoryFactory, interpret::{Interpret, InterpretError, InterpretName, InterpretStatus, Outcome}, inventory::{HostRole, Inventory}, modules::inventory::{HarmonyDiscoveryStrategy, LaunchDiscoverInventoryAgentScore}, score::Score, - topology::Topology, + topology::{BondConfig, BondMode, NetworkConfig, Topology}, }; #[derive(Debug, Clone, Serialize, Deserialize)] @@ -117,8 +119,16 @@ impl Interpret for DiscoverHostForRoleInterpret { .map(|(_, name)| name.clone()) .unwrap(); info!("Selected disk {} for node {}", disk_name, choice.summary()); + + let network_config = prompt_network_config(&choice)?; + host_repo - .save_role_mapping(&self.score.role, &choice, &disk_name) + .save_role_mapping( + &self.score.role, + &choice, + &disk_name, + &network_config, + ) .await?; chosen_hosts.push(choice); } @@ -179,3 +189,159 @@ impl Interpret for DiscoverHostForRoleInterpret { todo!() } } + +/// Interactively ask the user how the host's networking should be set up. +/// +/// Skips both prompts when the host has fewer than two network interfaces +/// — bonding requires at least two, and blacklisting a single NIC would leave +/// the host unreachable. The resulting [`NetworkConfig`] is persisted alongside +/// the role mapping so downstream Scores can act on it later. +fn prompt_network_config(host: &PhysicalHost) -> Result { + if host.network.len() < 2 { + info!( + "Host {} has {} network interface(s); skipping bond/blacklist prompts", + host.summary(), + host.network.len() + ); + return Ok(NetworkConfig::default()); + } + + let format_iface = |nic: &NetworkInterface| -> String { + let speed = nic + .speed_mbps + .map(|s| format!("{}Mbps", s)) + .unwrap_or_else(|| "?Mbps".to_string()); + let state = if nic.is_up { "up" } else { "down" }; + let ips = if nic.ipv4_addresses.is_empty() { + String::new() + } else { + format!(" [{}]", nic.ipv4_addresses.join(",")) + }; + format!( + "{} ({}) - {} - {} - driver {}{}", + nic.name, nic.mac_address, speed, state, nic.driver, ips + ) + }; + + let options: Vec<(String, String)> = host + .network + .iter() + .map(|nic| (format_iface(nic), nic.name.clone())) + .collect(); + + // --- Bond --- + let wants_bond = inquire::Confirm::new(&format!( + "Host {} has {} interfaces. Configure a network bond?", + host.summary(), + host.network.len() + )) + .with_default(false) + .prompt() + .map_err(|e| InterpretError::new(format!("Could not ask about bond: {e}")))?; + + let bond = if wants_bond { + let display_refs: Vec<&str> = options.iter().map(|(d, _)| d.as_str()).collect(); + let selected = inquire::MultiSelect::new( + "Select the interfaces to include in the bond:", + display_refs, + ) + .with_validator(|choices: &[inquire::list_option::ListOption<&&str>]| { + if choices.len() < 2 { + Ok(inquire::validator::Validation::Invalid( + "Select at least two interfaces for a bond".into(), + )) + } else { + Ok(inquire::validator::Validation::Valid) + } + }) + .prompt() + .map_err(|e| InterpretError::new(format!("Could not select bond interfaces: {e}")))?; + + let interfaces: Vec = options + .iter() + .filter(|(display, _)| selected.iter().any(|s| *s == display.as_str())) + .map(|(_, name)| name.clone()) + .collect(); + + let mode_choices = vec![ + BondMode::Lacp, + BondMode::ActiveBackup, + BondMode::BalanceRr, + BondMode::BalanceXor, + BondMode::Broadcast, + BondMode::BalanceTlb, + BondMode::BalanceAlb, + ]; + let mode = inquire::Select::new("Select the bond mode:", mode_choices) + .with_starting_cursor(0) + .prompt() + .map_err(|e| InterpretError::new(format!("Could not select bond mode: {e}")))?; + + info!( + "Bond configured for host {} on interfaces [{}] with mode {}", + host.summary(), + interfaces.join(", "), + mode + ); + Some(BondConfig { interfaces, mode }) + } else { + None + }; + + // --- Blacklist --- + // Candidates exclude any interface already claimed by the bond. + let bond_members: Vec<&String> = bond + .as_ref() + .map(|b| b.interfaces.iter().collect()) + .unwrap_or_default(); + + let blacklist_candidates: Vec<(String, String)> = options + .iter() + .filter(|(_, name)| !bond_members.iter().any(|b| *b == name)) + .cloned() + .collect(); + + let blacklisted_interfaces = if blacklist_candidates.is_empty() { + Vec::new() + } else { + let wants_blacklist = inquire::Confirm::new("Blacklist any remaining interface?") + .with_default(false) + .prompt() + .map_err(|e| InterpretError::new(format!("Could not ask about blacklist: {e}")))?; + + if wants_blacklist { + let display_refs: Vec<&str> = blacklist_candidates + .iter() + .map(|(d, _)| d.as_str()) + .collect(); + let selected = + inquire::MultiSelect::new("Select the interfaces to blacklist:", display_refs) + .prompt() + .map_err(|e| { + InterpretError::new(format!("Could not select blacklisted interfaces: {e}")) + })?; + + let names: Vec = blacklist_candidates + .iter() + .filter(|(display, _)| selected.iter().any(|s| *s == display.as_str())) + .map(|(_, name)| name.clone()) + .collect(); + + if !names.is_empty() { + info!( + "Blacklisted interfaces on host {}: {}", + host.summary(), + names.join(", ") + ); + } + names + } else { + Vec::new() + } + }; + + Ok(NetworkConfig { + bond, + blacklisted_interfaces, + }) +} diff --git a/migrations/20260421000000_add_network_config_to_host_role_mapping.sql b/migrations/20260421000000_add_network_config_to_host_role_mapping.sql new file mode 100644 index 00000000..98a213d7 --- /dev/null +++ b/migrations/20260421000000_add_network_config_to_host_role_mapping.sql @@ -0,0 +1,3 @@ +-- Add network_config column to host_role_mapping. +-- Stores a JSON-encoded NetworkConfig (bond selection + interface blacklist). +ALTER TABLE host_role_mapping ADD COLUMN network_config TEXT; -- 2.39.5 From bdba4dda275b0cfb21b9f4c7d607ba50b5fd86e6 Mon Sep 17 00:00:00 2001 From: Sylvain Tremblay Date: Tue, 21 Apr 2026 10:35:48 -0400 Subject: [PATCH 2/6] feat(discovery): tighten host summary and readability of prompts - PhysicalHost::summary() becomes terser and more informative: - Storage: "400 GB [8 GB, 477 GB]" (was "400 GB Storage (2 Disks [8 GB, 477 GB])"). Single-disk collapses to just the total. - Network: list every NIC as "[ip, mac]" with a count prefix (e.g. "3 NICs: [192.168.40.10, 98:fa:9b:03:17:6f], [00:e0:ed:7a:ec:4d], ..."). Single-NIC form drops the count and "s": "NIC: [ip, mac]". NICs without an IPv4 render as "[mac]". - Promote the inventory agent's Chipset { vendor, name } into a "system-product-name" label during host conversion (both MDNS and CIDR flows), so summary()'s first field shows "LENOVO 3136" instead of falling back to the HostCategory string ("Server"). Extracted into build_discovered_host_labels() to keep the two conversion sites in sync. When the chipset is blank, the old category fallback still applies. - Print a blank line before every interactive inquire prompt in the discovery flow (role pick, disk pick, bond confirm/multi-select/mode, blacklist confirm/multi-select) so prompts stand out from the preceding log output on the terminal. --- harmony/src/domain/hardware/mod.rs | 46 ++++++++------------ harmony/src/modules/inventory/discovery.rs | 7 ++++ harmony/src/modules/inventory/mod.rs | 49 +++++++++++++++++----- 3 files changed, 63 insertions(+), 39 deletions(-) diff --git a/harmony/src/domain/hardware/mod.rs b/harmony/src/domain/hardware/mod.rs index 2d7a0347..b883318b 100644 --- a/harmony/src/domain/hardware/mod.rs +++ b/harmony/src/domain/hardware/mod.rs @@ -94,7 +94,6 @@ impl PhysicalHost { if !self.storage.is_empty() { let total_storage_bytes = self.storage.iter().map(|d| d.size_bytes).sum::(); let drive_count = self.storage.len(); - let first_drive_model = &self.storage[0].model; // Helper to format bytes into TB or GB let format_storage = |bytes: u64| { @@ -115,40 +114,31 @@ impl PhysicalHost { .collect::>() .join(", "); - format!( - "{} Storage ({} Disks [{}])", - format_storage(total_storage_bytes), - drive_count, - drive_sizes - ) + format!("{} [{}]", format_storage(total_storage_bytes), drive_sizes) } else { - format!( - "{} Storage ({})", - format_storage(total_storage_bytes), - first_drive_model - ) + format_storage(total_storage_bytes) }; parts.push(storage_summary); } - // Part 5: Network Information - // Prioritize an "up" interface with an IPv4 address - let best_nic = self - .network - .iter() - .find(|n| n.is_up && !n.ipv4_addresses.is_empty()) - .or_else(|| self.network.first()); + // Part 5: Network Information — list every NIC with its IPv4 (when present) and MAC. + if !self.network.is_empty() { + let per_nic: Vec = self + .network + .iter() + .map(|nic| { + let mac = nic.mac_address.to_string(); + match nic.ipv4_addresses.first() { + Some(ip) => format!("[{}, {}]", ip, mac), + None => format!("[{}]", mac), + } + }) + .collect(); - if let Some(nic) = best_nic { - let speed = nic - .speed_mbps - .map(|s| format!("{}Gbps", s / 1000)) - .unwrap_or_else(|| "N/A".to_string()); - let mac = nic.mac_address.to_string(); - let nic_summary = if let Some(ip) = nic.ipv4_addresses.first() { - format!("NIC: {} ({}, {})", speed, ip, mac) + let nic_summary = if per_nic.len() == 1 { + format!("NIC: {}", per_nic[0]) } else { - format!("NIC: {} ({})", speed, mac) + format!("{} NICs: {}", per_nic.len(), per_nic.join(", ")) }; parts.push(nic_summary); } diff --git a/harmony/src/modules/inventory/discovery.rs b/harmony/src/modules/inventory/discovery.rs index 9d037c3e..9aedef30 100644 --- a/harmony/src/modules/inventory/discovery.rs +++ b/harmony/src/modules/inventory/discovery.rs @@ -70,6 +70,7 @@ impl Interpret for DiscoverHostForRoleInterpret { continue; } + println!(); let ans = inquire::Select::new( &format!("Select the node to be used for role {:?}:", self.score.role), all_hosts, @@ -105,6 +106,7 @@ impl Interpret for DiscoverHostForRoleInterpret { let display_refs: Vec<&str> = disk_choices.iter().map(|(d, _)| d.as_str()).collect(); + println!(); let disk_choice = inquire::Select::new( &format!("Select the disk to use on host {}:", choice.summary()), display_refs, @@ -230,6 +232,7 @@ fn prompt_network_config(host: &PhysicalHost) -> Result Result = options.iter().map(|(d, _)| d.as_str()).collect(); + println!(); let selected = inquire::MultiSelect::new( "Select the interfaces to include in the bond:", display_refs, @@ -272,6 +276,7 @@ fn prompt_network_config(host: &PhysicalHost) -> Result Result Result Vec