feat: capture network intent at host discovery #267
@@ -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"
|
||||
}
|
||||
32
.sqlx/query-3b71d7d7ae75e75ec3ef1df2cd3c4d18520b9d56dd328b7edf576af9dac3c2c0.json
generated
Normal file
32
.sqlx/query-3b71d7d7ae75e75ec3ef1df2cd3c4d18520b9d56dd328b7edf576af9dac3c2c0.json
generated
Normal 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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
12
.sqlx/query-779c5aa1643e714051ba141e5cc5788846925324bfb7d79662026fdc3e33c0ca.json
generated
Normal file
12
.sqlx/query-779c5aa1643e714051ba141e5cc5788846925324bfb7d79662026fdc3e33c0ca.json
generated
Normal 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"
|
||||
}
|
||||
@@ -16,7 +16,7 @@
|
||||
{
|
||||
"name": "data: Json<PhysicalHost>",
|
||||
"ordinal": 2,
|
||||
"type_info": "Blob"
|
||||
"type_info": "Null"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
|
||||
20
.sqlx/query-c7ca191faaa23b3ec5019f8c4910f666db9c6c2be22ffe563be4b7caef645bd1.json
generated
Normal file
20
.sqlx/query-c7ca191faaa23b3ec5019f8c4910f666db9c6c2be22ffe563be4b7caef645bd1.json
generated
Normal 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
13
Cargo.lock
generated
@@ -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"
|
||||
|
||||
15
examples/harmony_host_discovery/Cargo.toml
Normal file
15
examples/harmony_host_discovery/Cargo.toml
Normal 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
|
||||
4
examples/harmony_host_discovery/env.sh
Normal file
4
examples/harmony_host_discovery/env.sh
Normal 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
|
||||
27
examples/harmony_host_discovery/src/main.rs
Normal file
27
examples/harmony_host_discovery/src/main.rs
Normal 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();
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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)) => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user