feat: capture network intent at host discovery #267

Merged
stremblay merged 6 commits from feat/discover-networking into master 2026-04-21 16:20:49 +00:00
19 changed files with 649 additions and 100 deletions

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "DELETE FROM host_role_mapping WHERE host_id = ?",
"describe": {
"columns": [],
"parameters": {
"Right": 1
},
"nullable": []
},
"hash": "779c5aa1643e714051ba141e5cc5788846925324bfb7d79662026fdc3e33c0ca"
}

View File

@@ -16,7 +16,7 @@
{
"name": "data: Json<PhysicalHost>",
"ordinal": 2,
"type_info": "Blob"
"type_info": "Null"
}
],
"parameters": {

View File

@@ -0,0 +1,20 @@
{
"db_name": "SQLite",
"query": "SELECT data as \"data!: Vec<u8>\" FROM physical_hosts WHERE id = ? ORDER BY version_id DESC LIMIT 1",
"describe": {
"columns": [
{
"name": "data!: Vec<u8>",
"ordinal": 0,
"type_info": "Null"
}
],
"parameters": {
"Right": 1
},
"nullable": [
false
]
},
"hash": "c7ca191faaa23b3ec5019f8c4910f666db9c6c2be22ffe563be4b7caef645bd1"
}

13
Cargo.lock generated
View File

@@ -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"

View File

@@ -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

View File

@@ -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

View File

@@ -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();
}

View File

@@ -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<String> {
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::<u32>();
let total_threads = self.cpus.iter().map(|c| c.threads).sum::<u32>();
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::<u64>();
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::<Vec<_>>()
.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<String>) {
if self.network.is_empty() {
return;
}
let per_nic: Vec<String> = 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 {

View File

@@ -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 {

View File

@@ -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<Vec<(PhysicalHost, HostConfig)>, 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<Option<HostRoleMapping>, RepoError>;
}

View File

@@ -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<String>,
#[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<BondConfig>,
pub blacklisted_interfaces: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BondConfig {
pub interfaces: Vec<String>,
pub mode: LaggProtocol,
}

View File

@@ -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<Self, RepoError> {
// 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<u8>" 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<Option<HostRoleMapping>, RepoError> {
struct Row {
role: HostRole,
installation_device: Option<String>,
network_config: Option<String>,
}
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::<NetworkConfig>(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<String>,
network_config: Option<String>,
}
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));
}

View File

@@ -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<T: Topology> Interpret<T> 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<T: Topology> Interpret<T> 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<T: Topology> Interpret<T> 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<T: Topology> Interpret<T> 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<T: Topology> Interpret<T> 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<bool, InterpretError> {
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: <short summary>" 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<NetworkConfig, InterpretError> {
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<String> = 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<String> = 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,
})
}

View File

@@ -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<Label> {
let mut labels = vec![Label {
name: "discovered-by".to_string(),
value: "harmony-inventory-agent".to_string(),
}];
let vendor = chipset.vendor.trim();
let name = chipset.name.trim();
let product = match (vendor.is_empty(), name.is_empty()) {
(true, true) => None,
(true, false) => Some(name.to_string()),
(false, true) => Some(vendor.to_string()),
(false, false) => Some(format!("{vendor} {name}")),
};
if let Some(value) = product {
labels.push(Label {
name: "system-product-name".to_string(),
value,
});
}
labels
}
/// This launches an harmony_inventory_agent discovery process
/// This will allow us to register/update hosts running harmony_inventory_agent
/// from LAN in the Harmony inventory
@@ -154,27 +185,27 @@ impl DiscoverInventoryAgentInterpret {
storage_controller: _,
memory_modules,
cpus,
chipset: _,
network_interfaces,
chipset,
mut network_interfaces,
management_interface: _,
host_uuid,
} = host;
// Sort NICs by name for deterministic display (e.g. f0 before f1)
// and stable serialization — keeps save()'s byte-equality dedup
// correct when the agent reports NICs in different sysfs-walk order.
network_interfaces.sort_by(|a, b| a.name.cmp(&b.name));
let host = PhysicalHost {
id: Id::from(host_uuid),
category: HostCategory::Server,
network: network_interfaces,
storage: storage_drives,
labels: vec![Label {
name: "discovered-by".to_string(),
value: "harmony-inventory-agent".to_string(),
}],
labels: build_discovered_host_labels(&chipset),
memory_modules,
cpus,
};
// FIXME only save the host when it is new or something changed in it.
// we currently are saving the host every time it is discovered.
let repo = InventoryRepositoryFactory::build()
.await
.map_err(|e| format!("Could not build repository : {e}"))
@@ -183,11 +214,7 @@ impl DiscoverInventoryAgentInterpret {
.await
.map_err(|e| format!("Could not save host : {e}"))
.unwrap();
info!(
"Saved new host id {}, summary : {}",
host.id,
host.summary()
);
info!("Discovered host {}, summary : {}", host.id, host.summary());
});
}
_ => debug!("Unhandled event {event:?}"),
@@ -248,24 +275,24 @@ impl DiscoverInventoryAgentInterpret {
// Reuse the same conversion to PhysicalHost as MDNS flow
let harmony_inventory_agent::hwinfo::PhysicalHost {
storage_drives,
storage_controller,
storage_controller: _,
memory_modules,
cpus,
chipset,
network_interfaces,
management_interface,
mut network_interfaces,
management_interface: _,
host_uuid,
} = host;
// Sort NICs by name for deterministic ordering (see MDNS flow above).
network_interfaces.sort_by(|a, b| a.name.cmp(&b.name));
let host = PhysicalHost {
id: Id::from(host_uuid),
category: HostCategory::Server,
network: network_interfaces,
storage: storage_drives,
labels: vec![Label {
name: "discovered-by".to_string(),
value: "harmony-inventory-agent".to_string(),
}],
labels: build_discovered_host_labels(&chipset),
memory_modules,
cpus,
};
@@ -278,7 +305,7 @@ impl DiscoverInventoryAgentInterpret {
if let Err(e) = repo.save(&host).await {
log::debug!("Failed to save host {}: {e}", host.id);
} else {
info!("Saved host id {}, summary : {}", host.id, host.summary());
info!("Discovered host {}, summary : {}", host.id, host.summary());
}
}
Ok(Err(e)) => {

View File

@@ -1,6 +1,6 @@
//! Vendor-neutral firewall and network types for infrastructure-as-code.
use serde::Serialize;
use serde::{Deserialize, Serialize};
use std::fmt;
/// Firewall rule action.
@@ -99,7 +99,7 @@ impl fmt::Display for VipMode {
}
/// Link aggregation protocol.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum LaggProtocol {
/// LACP (802.3ad) — negotiated aggregation with the switch.
Lacp,

View File

@@ -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;