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-3b71d7d7ae75e75ec3ef1df2cd3c4d18520b9d56dd328b7edf576af9dac3c2c0.json b/.sqlx/query-3b71d7d7ae75e75ec3ef1df2cd3c4d18520b9d56dd328b7edf576af9dac3c2c0.json new file mode 100644 index 00000000..f317859f --- /dev/null +++ b/.sqlx/query-3b71d7d7ae75e75ec3ef1df2cd3c4d18520b9d56dd328b7edf576af9dac3c2c0.json @@ -0,0 +1,32 @@ +{ + "db_name": "SQLite", + "query": "SELECT role as \"role: HostRole\", installation_device, network_config FROM host_role_mapping WHERE host_id = ? ORDER BY id DESC LIMIT 1", + "describe": { + "columns": [ + { + "name": "role: HostRole", + "ordinal": 0, + "type_info": "Text" + }, + { + "name": "installation_device", + "ordinal": 1, + "type_info": "Text" + }, + { + "name": "network_config", + "ordinal": 2, + "type_info": "Text" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false, + true, + true + ] + }, + "hash": "3b71d7d7ae75e75ec3ef1df2cd3c4d18520b9d56dd328b7edf576af9dac3c2c0" +} 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-779c5aa1643e714051ba141e5cc5788846925324bfb7d79662026fdc3e33c0ca.json b/.sqlx/query-779c5aa1643e714051ba141e5cc5788846925324bfb7d79662026fdc3e33c0ca.json new file mode 100644 index 00000000..082e702c --- /dev/null +++ b/.sqlx/query-779c5aa1643e714051ba141e5cc5788846925324bfb7d79662026fdc3e33c0ca.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "DELETE FROM host_role_mapping WHERE host_id = ?", + "describe": { + "columns": [], + "parameters": { + "Right": 1 + }, + "nullable": [] + }, + "hash": "779c5aa1643e714051ba141e5cc5788846925324bfb7d79662026fdc3e33c0ca" +} 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/.sqlx/query-c7ca191faaa23b3ec5019f8c4910f666db9c6c2be22ffe563be4b7caef645bd1.json b/.sqlx/query-c7ca191faaa23b3ec5019f8c4910f666db9c6c2be22ffe563be4b7caef645bd1.json new file mode 100644 index 00000000..cbe2716e --- /dev/null +++ b/.sqlx/query-c7ca191faaa23b3ec5019f8c4910f666db9c6c2be22ffe563be4b7caef645bd1.json @@ -0,0 +1,20 @@ +{ + "db_name": "SQLite", + "query": "SELECT data as \"data!: Vec\" FROM physical_hosts WHERE id = ? ORDER BY version_id DESC LIMIT 1", + "describe": { + "columns": [ + { + "name": "data!: Vec", + "ordinal": 0, + "type_info": "Null" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false + ] + }, + "hash": "c7ca191faaa23b3ec5019f8c4910f666db9c6c2be22ffe563be4b7caef645bd1" +} 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/hardware/mod.rs b/harmony/src/domain/hardware/mod.rs index 2d7a0347..1bfe2c0c 100644 --- a/harmony/src/domain/hardware/mod.rs +++ b/harmony/src/domain/hardware/mod.rs @@ -33,6 +33,21 @@ impl PhysicalHost { } pub fn summary(&self) -> String { + let mut parts = self.summary_parts_through_storage(); + self.append_network_summary(&mut parts); + parts.join(" | ") + } + + /// Same shape as [`Self::summary`] but drops the network portion — useful + /// for compact contexts like the `Host:` header above interactive + /// `inquire` prompts, where the NIC list is too wide for the terminal. + pub fn summary_short(&self) -> String { + self.summary_parts_through_storage().join(" | ") + } + + /// Builds the first four sections of the summary (model, CPU, RAM, storage). + /// Shared between [`Self::summary`] and [`Self::summary_short`]. + fn summary_parts_through_storage(&self) -> Vec { let mut parts = Vec::new(); // Part 1: System Model (from labels) or Category as a fallback @@ -49,15 +64,17 @@ impl PhysicalHost { let cpu_count = self.cpus.len(); let total_cores = self.cpus.iter().map(|c| c.cores).sum::(); let total_threads = self.cpus.iter().map(|c| c.threads).sum::(); - let model_name = &self.cpus[0].model; + let model_name = self.cpus[0].model.trim(); - let cpu_summary = if cpu_count > 1 { - format!( - "{}x {} ({}c/{}t)", - cpu_count, model_name, total_cores, total_threads - ) - } else { - format!("{} ({}c/{}t)", model_name, total_cores, total_threads) + // Agents sometimes report a blank model (e.g. when /proc/cpuinfo is + // unreadable); collapse those cases to avoid stray double-spaces. + let cpu_summary = match (cpu_count > 1, model_name.is_empty()) { + (true, true) => format!("{cpu_count}x CPU ({total_cores}c/{total_threads}t)"), + (true, false) => { + format!("{cpu_count}x {model_name} ({total_cores}c/{total_threads}t)") + } + (false, true) => format!("{total_cores}c/{total_threads}t"), + (false, false) => format!("{model_name} ({total_cores}c/{total_threads}t)"), }; parts.push(cpu_summary); } @@ -94,7 +111,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,45 +131,39 @@ 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 + parts + } + + /// Appends the per-NIC network section to an existing parts list. + fn append_network_summary(&self, parts: &mut Vec) { + if self.network.is_empty() { + return; + } + let per_nic: Vec = self .network .iter() - .find(|n| n.is_up && !n.ipv4_addresses.is_empty()) - .or_else(|| self.network.first()); + .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) - } else { - format!("NIC: {} ({})", speed, mac) - }; - parts.push(nic_summary); - } - - parts.join(" | ") + let nic_summary = if per_nic.len() == 1 { + format!("NIC: {}", per_nic[0]) + } else { + format!("{} NICs: {}", per_nic.len(), per_nic.join(", ")) + }; + parts.push(nic_summary); } pub fn parts_list(&self) -> String { diff --git a/harmony/src/domain/inventory/mod.rs b/harmony/src/domain/inventory/mod.rs index 10fabda8..50868cb8 100644 --- a/harmony/src/domain/inventory/mod.rs +++ b/harmony/src/domain/inventory/mod.rs @@ -73,6 +73,16 @@ pub enum HostRole { Worker, } +/// A persisted role-to-host assignment: the role that was chosen, plus the +/// operational config captured at discovery time (install disk, bond + +/// blacklist). Returned when looking up "does this host already have a +/// mapping?" so the UI can show what will be replaced before overwriting. +#[derive(Debug, Clone)] +pub struct HostRoleMapping { + pub role: HostRole, + pub host_config: crate::topology::HostConfig, +} + impl fmt::Display for HostRole { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { match self { diff --git a/harmony/src/domain/inventory/repository.rs b/harmony/src/domain/inventory/repository.rs index e6a4eea8..5a83ad83 100644 --- a/harmony/src/domain/inventory/repository.rs +++ b/harmony/src/domain/inventory/repository.rs @@ -1,8 +1,12 @@ use async_trait::async_trait; use crate::{ - hardware::PhysicalHost, interpret::InterpretError, inventory::HostRole, topology::HostConfig, + hardware::PhysicalHost, + interpret::InterpretError, + inventory::{HostRole, HostRoleMapping}, + topology::{HostConfig, NetworkConfig}, }; +use harmony_types::id::Id; /// Errors that can occur within the repository layer. #[derive(thiserror::Error, Debug)] @@ -35,10 +39,18 @@ pub trait InventoryRepository: Send + Sync + 'static { &self, role: &HostRole, ) -> Result, RepoError>; + /// Insert-or-replace the role mapping for this host. Any prior mapping + /// rows for `host.id` are deleted first (in the same transaction) so + /// `host_role_mapping` holds at most one row per host. async fn save_role_mapping( &self, role: &HostRole, host: &PhysicalHost, installation_device: &String, + network_config: &NetworkConfig, ) -> Result<(), RepoError>; + + /// Return the current role mapping for a host, if any. Used at discovery + /// time to ask the operator whether to overwrite or cancel. + async fn get_role_mapping(&self, host_id: &Id) -> Result, RepoError>; } diff --git a/harmony/src/domain/topology/host_binding.rs b/harmony/src/domain/topology/host_binding.rs index 63352762..7bea060d 100644 --- a/harmony/src/domain/topology/host_binding.rs +++ b/harmony/src/domain/topology/host_binding.rs @@ -1,5 +1,6 @@ use derive_new::new; -use serde::Serialize; +use harmony_types::firewall::LaggProtocol; +use serde::{Deserialize, Serialize}; use crate::hardware::PhysicalHost; @@ -20,4 +21,23 @@ 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: LaggProtocol, } diff --git a/harmony/src/infra/inventory/sqlite.rs b/harmony/src/infra/inventory/sqlite.rs index 3ce1654f..0cff734e 100644 --- a/harmony/src/infra/inventory/sqlite.rs +++ b/harmony/src/infra/inventory/sqlite.rs @@ -1,12 +1,16 @@ use crate::{ hardware::PhysicalHost, - inventory::{HostRole, InventoryRepository, RepoError}, - topology::HostConfig, + inventory::{HostRole, HostRoleMapping, InventoryRepository, RepoError}, + topology::{HostConfig, NetworkConfig}, }; use async_trait::async_trait; use harmony_types::id::Id; -use log::info; -use sqlx::{Pool, Sqlite, SqlitePool, migrate::MigrateDatabase}; +use log::{info, warn}; +use sqlx::{ + Pool, Sqlite, + sqlite::{SqliteConnectOptions, SqliteJournalMode, SqlitePoolOptions}, +}; +use std::str::FromStr; /// A thread-safe, connection-pooled repository using SQLite. #[derive(Debug)] @@ -16,18 +20,18 @@ pub struct SqliteInventoryRepository { impl SqliteInventoryRepository { pub async fn new(database_url: &str) -> Result { - // Ensure the database file exists for SQLite - if database_url.starts_with("sqlite:") { - let path = database_url.trim_start_matches("sqlite:"); - if !path.contains(":memory:") && !std::path::Path::new(path).exists() { - sqlx::any::install_default_drivers(); - sqlx::Sqlite::create_database(database_url) - .await - .map_err(|e| RepoError::ConnectionFailed(e.to_string()))?; - } - } + // Use the classic rollback journal (DELETE) rather than sqlx's WAL + // default so we don't leave `.sqlite-wal` / `.sqlite-shm` files next + // to the DB: this is a single-process CLI, WAL's concurrent-reader + // benefit is wasted. `create_if_missing(true)` replaces the manual + // `Sqlite::create_database` dance the code used to do. + let options = SqliteConnectOptions::from_str(database_url) + .map_err(|e| RepoError::ConnectionFailed(e.to_string()))? + .create_if_missing(true) + .journal_mode(SqliteJournalMode::Delete); - let pool = SqlitePool::connect(database_url) + let pool = SqlitePoolOptions::new() + .connect_with(options) .await .map_err(|e| RepoError::ConnectionFailed(e.to_string()))?; @@ -50,6 +54,24 @@ impl InventoryRepository for SqliteInventoryRepository { let id = Id::default().to_string(); let host_id = host.id.to_string(); + // Skip the insert if the most recent row for this host is byte-identical: + // discovery is naturally a polling activity (mDNS is continuous, CIDR scans get + // re-run) and we don't want an unbounded pile of identical version rows. Real + // changes still produce a new version row (audit trail for free). + let latest = sqlx::query!( + r#"SELECT data as "data!: Vec" FROM physical_hosts WHERE id = ? ORDER BY version_id DESC LIMIT 1"#, + host_id + ) + .fetch_optional(&self.pool) + .await?; + + if let Some(row) = latest { + if row.data == data { + info!("Host '{}' unchanged, skipping save", host.id); + return Ok(()); + } + } + sqlx::query!( "INSERT INTO physical_hosts (id, version_id, data) VALUES (?, ?, ?)", host_id, @@ -109,26 +131,85 @@ 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()))?; + + // Replace atomically: DELETE any prior rows for this host_id (there should + // be at most one, but older data may have dups) then INSERT the new one. + // Wrapped in a transaction so a concurrent reader never sees zero rows. + let mut tx = self.pool.begin().await?; + + sqlx::query!("DELETE FROM host_role_mapping WHERE host_id = ?", host_id) + .execute(&mut *tx) + .await?; 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) + .execute(&mut *tx) .await?; + tx.commit().await?; + info!("Saved role mapping for host '{}' as '{:?}'", host.id, role); Ok(()) } + async fn get_role_mapping(&self, host_id: &Id) -> Result, RepoError> { + struct Row { + role: HostRole, + installation_device: Option, + network_config: Option, + } + + let host_id_str = host_id.to_string(); + let row = sqlx::query_as!( + Row, + r#"SELECT role as "role: HostRole", installation_device, network_config FROM host_role_mapping WHERE host_id = ? ORDER BY id DESC LIMIT 1"#, + host_id_str, + ) + .fetch_optional(&self.pool) + .await?; + + let Some(row) = row else { return Ok(None) }; + + // Tolerate unparseable network_config: log loudly and fall back to + // defaults so the operator can still be shown the existing mapping + // and choose "Update" to overwrite the bad row. This covers stored + // rows from older enum shapes and any accidental corruption. + let network_config = match row.network_config.as_deref() { + Some(json) => match serde_json::from_str::(json) { + Ok(cfg) => cfg, + Err(e) => { + warn!( + "Discarding unreadable network_config for host '{host_id}': {e}. The existing mapping will be shown with empty network config; pick 'Update' to replace it." + ); + NetworkConfig::default() + } + }, + None => NetworkConfig::default(), + }; + + Ok(Some(HostRoleMapping { + role: row.role, + host_config: HostConfig { + installation_device: row.installation_device, + network_config, + }, + })) + } + async fn get_hosts_for_role( &self, role: &HostRole, @@ -136,13 +217,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 +241,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..e800a211 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_types::id::Id; +use harmony_inventory_agent::hwinfo::NetworkInterface; +use harmony_types::{firewall::LaggProtocol, 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}, + inventory::{HostRole, HostRoleMapping, Inventory}, modules::inventory::{HarmonyDiscoveryStrategy, LaunchDiscoverInventoryAgentScore}, score::Score, - topology::Topology, + topology::{BondConfig, NetworkConfig, Topology}, }; #[derive(Debug, Clone, Serialize, Deserialize)] @@ -68,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, @@ -77,6 +80,18 @@ impl Interpret for DiscoverHostForRoleInterpret { match ans { Ok(choice) => { + // If the host is already mapped, tell the operator what's there + // and let them bail out before re-answering every prompt. + if let Some(existing) = host_repo.get_role_mapping(&choice.id).await? { + if !confirm_overwrite_existing_mapping(&choice, &existing)? { + info!( + "Cancelled: kept existing mapping for host {}", + choice.summary() + ); + continue; + } + } + info!( "Assigned role {:?} for node {}", self.score.role, @@ -103,11 +118,9 @@ impl Interpret for DiscoverHostForRoleInterpret { let display_refs: Vec<&str> = disk_choices.iter().map(|(d, _)| d.as_str()).collect(); - let disk_choice = inquire::Select::new( - &format!("Select the disk to use on host {}:", choice.summary()), - display_refs, - ) - .prompt(); + print_host_header(&choice); + let disk_choice = + inquire::Select::new("Select the disk to use:", display_refs).prompt(); match disk_choice { Ok(selected_display) => { @@ -117,8 +130,20 @@ 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)?; + + // Visual break between the last prompt's answer and the + // logs that follow (save, loop progress, next iteration). + println!(); + 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 +204,228 @@ impl Interpret for DiscoverHostForRoleInterpret { todo!() } } + +/// Show the existing role mapping for a host and ask whether to overwrite it. +/// +/// Returns `true` if the operator chose to overwrite (the caller proceeds with +/// disk/network prompts + a fresh save), `false` if they cancelled (caller +/// skips this host and continues the selection loop). +fn confirm_overwrite_existing_mapping( + host: &PhysicalHost, + existing: &HostRoleMapping, +) -> Result { + print_host_header(host); + println!("This host already has a role mapping:"); + println!(" Role: {}", existing.role); + println!( + " Installation disk: {}", + existing + .host_config + .installation_device + .as_deref() + .unwrap_or("(none)") + ); + match &existing.host_config.network_config.bond { + Some(bond) => println!(" Bond: {} on [{}]", bond.mode, bond.interfaces.join(", ")), + None => println!(" Bond: none"), + } + let blacklist = &existing.host_config.network_config.blacklisted_interfaces; + if !blacklist.is_empty() { + println!(" Blacklisted: {}", blacklist.join(", ")); + } + + let action = inquire::Select::new( + "What do you want to do?", + vec!["Update (overwrite the existing mapping)", "Cancel"], + ) + .prompt() + .map_err(|e| InterpretError::new(format!("Could not prompt: {e}")))?; + + Ok(action.starts_with("Update")) +} + +/// Print a blank line and a "Host: " header above the next prompt. +/// +/// Harmonizes every host-specific `inquire` question in the discovery flow so +/// the operator always sees which machine the prompt refers to — the `Host:` +/// line sits directly above the `? ...` question rendered by inquire. The +/// short-form summary omits the NIC list so the header fits on one screen +/// width; full NIC details still appear inside the bond/blacklist pickers. +fn print_host_header(host: &PhysicalHost) { + println!(); + println!("Host: {}", host.summary_short()); +} + +/// 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 --- + print_host_header(host); + let wants_bond = inquire::Confirm::new("Configure a network bond?") + .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(); + print_host_header(host); + 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(); + + // Tuple-based picker so we can render fuller descriptions than the + // plain `Display` gives. Keep LACP first — it's the HA default. + let mode_choices: Vec<(String, LaggProtocol)> = vec![ + ( + "LACP (802.3ad) — negotiated aggregation with the switch".to_string(), + LaggProtocol::Lacp, + ), + ( + "Failover — single active link, others standby".to_string(), + LaggProtocol::Failover, + ), + ( + "Load Balance — distribute traffic across links".to_string(), + LaggProtocol::LoadBalance, + ), + ( + "Round Robin — rotate through links per packet".to_string(), + LaggProtocol::RoundRobin, + ), + ]; + let display_refs: Vec<&str> = mode_choices.iter().map(|(d, _)| d.as_str()).collect(); + print_host_header(host); + let selected_display = inquire::Select::new("Select the bond mode:", display_refs) + .with_starting_cursor(0) + .prompt() + .map_err(|e| InterpretError::new(format!("Could not select bond mode: {e}")))?; + let mode = mode_choices + .iter() + .find(|(d, _)| d.as_str() == selected_display) + .map(|(_, p)| p.clone()) + .expect("selected display must map back to a LaggProtocol"); + + 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 { + print_host_header(host); + 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(); + print_host_header(host); + 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/harmony/src/modules/inventory/mod.rs b/harmony/src/modules/inventory/mod.rs index 1bdccd33..acfa7aca 100644 --- a/harmony/src/modules/inventory/mod.rs +++ b/harmony/src/modules/inventory/mod.rs @@ -35,6 +35,37 @@ use crate::{ }; use harmony_types::id::Id; +/// Build the `labels` list for a host discovered via the inventory agent. +/// +/// Always includes the `discovered-by` provenance label. Also promotes the +/// agent's `Chipset { vendor, name }` into a `system-product-name` label so +/// `PhysicalHost::summary()` can show something like "LENOVO 3136" instead of +/// falling back to the generic "Server" category string. Skips that label when +/// both chipset fields are blank. +fn build_discovered_host_labels(chipset: &harmony_inventory_agent::hwinfo::Chipset) -> Vec