From cfdc8bfa230a24a20918b7cb443c4fbbb95824b8 Mon Sep 17 00:00:00 2001 From: Anatolii Kurotych Date: Wed, 8 Jul 2026 20:11:45 +0300 Subject: [PATCH 01/10] feat(host-support): implement collect_lldp_neighbors Move LLDP parsing (get_port_lldp_info and friends) out of the DPU-specific enumeration into a shared lldp_collector module. Signed-off-by: Anatolii Kurotych --- .../host-support/src/hardware_enumeration.rs | 4 +- .../src/hardware_enumeration/dpu.rs | 205 +----------- crates/host-support/src/lib.rs | 4 + crates/host-support/src/lldp_collector.rs | 303 ++++++++++++++++++ crates/host-support/src/lldp_reporter.rs | 0 5 files changed, 314 insertions(+), 202 deletions(-) create mode 100644 crates/host-support/src/lldp_collector.rs create mode 100644 crates/host-support/src/lldp_reporter.rs diff --git a/crates/host-support/src/hardware_enumeration.rs b/crates/host-support/src/hardware_enumeration.rs index 4aef6052b4..31dee20ca5 100644 --- a/crates/host-support/src/hardware_enumeration.rs +++ b/crates/host-support/src/hardware_enumeration.rs @@ -42,7 +42,7 @@ mod tpm; /// Path where the init container writes the hardware snapshot, and the containerized agent reads it from. pub const HW_CACHE_PATH: &str = "/data/hw_output.json"; -const PCI_SUBCLASS: &str = "ID_PCI_SUBCLASS_FROM_DATABASE"; +pub(crate) const PCI_SUBCLASS: &str = "ID_PCI_SUBCLASS_FROM_DATABASE"; const PCI_DEV_PATH: &str = "DEVPATH"; const PCI_MODEL: &str = "ID_MODEL_FROM_DATABASE"; const PCI_SLOT_NAME: &str = "PCI_SLOT_NAME"; @@ -154,7 +154,7 @@ fn convert_udev_to_mac(udev: String) -> Result Ok(mac) } -fn convert_property_to_string<'a>( +pub fn convert_property_to_string<'a>( name: &'a str, default_value: &'a str, device: &'a Device, diff --git a/crates/host-support/src/hardware_enumeration/dpu.rs b/crates/host-support/src/hardware_enumeration/dpu.rs index aa72c21c48..fb8df7c851 100644 --- a/crates/host-support/src/hardware_enumeration/dpu.rs +++ b/crates/host-support/src/hardware_enumeration/dpu.rs @@ -15,18 +15,16 @@ * limitations under the License. */ -use std::collections::HashMap; -use std::net::IpAddr; use std::thread::sleep; use std::time::{Duration, Instant}; use carbide_utils::cmd::{Cmd, CmdError}; use regex::Regex; use rpc::machine_discovery::{DpuData, LldpSwitchData}; -use serde::{Deserialize, Serialize}; -use serde_with::{OneOrMany, serde_as}; use tracing::{debug, warn}; +use crate::lldp_collector::get_port_lldp_info; + const LLDP_PORTS: &[&str] = &["p0", "p1", "oob_net0"]; #[derive(thiserror::Error, Debug)] @@ -43,76 +41,6 @@ pub enum DpuEnumerationError { Lldp(String), } -#[derive(Debug, Deserialize, Serialize, Clone)] -pub struct LldpCapabilityData { - #[serde(rename = "type")] - pub capability_type: String, - pub enabled: bool, -} - -#[derive(Debug, Deserialize, Serialize, Clone)] -pub struct LldpIdData { - #[serde(rename = "type")] - pub id_type: String, - pub value: String, -} - -#[serde_as] -#[derive(Debug, Deserialize, Serialize, Clone)] -pub struct LldpChassisData { - pub id: LldpIdData, - pub descr: String, - #[serde(rename = "mgmt-ip", default)] - #[serde_as(as = "OneOrMany<_>")] - pub management_ip_address: Vec, // we get an array with ipv4 and ipv6 addresses - #[serde(default)] - pub capability: Vec, -} - -#[derive(Debug, Deserialize, Serialize, Clone)] -pub struct LldpPortData { - pub id: LldpIdData, - pub descr: Option, - pub ttl: String, -} - -#[derive(Debug, Deserialize, Serialize, Clone)] -pub struct LldpQueryData { - pub age: String, - pub chassis: HashMap, // the key in this hash is the tor name - pub port: LldpPortData, -} - -#[derive(Debug, Deserialize, Serialize, Clone)] -pub struct LldpInterface { - pub interface: HashMap, // the key in this hash is the port #, eg. p0 -} - -#[derive(Debug, Deserialize, Serialize, Clone)] -pub struct LldpResponse { - pub lldp: LldpInterface, -} - -/// Get LLDP port info. -pub fn get_lldp_port_info(port: &str) -> Result { - if cfg!(test) { - const TEST_DATA: &str = "test/lldp_query.json"; - std::fs::read_to_string(TEST_DATA).map_err(|e| { - warn!("Could not read LLDP json: {e}"); - DpuEnumerationError::Read(TEST_DATA, e.to_string()) - }) - } else { - let lldp_cmd = format!("lldpcli -f json show neighbors ports {port}"); - Cmd::new("bash") - .args(vec!["-c", lldp_cmd.as_str()]) - .output() - .map_err(|e| { - warn!("Could not discover LLDP peer for {port}, {e}"); - DpuEnumerationError::Lldp(e.to_string()) - }) - } -} - pub fn wait_until_all_ports_available() { const MAX_TIMEOUT: Duration = Duration::from_secs(60 * 5); const RETRY_TIME: Duration = Duration::from_secs(5); @@ -155,48 +83,6 @@ pub fn is_lldp_working(_fw_version: &str) -> bool { false } -/// query lldp info for high speed ports p0..1, oob_net0 (some ports may not exist, warn on errors) -/// translate to simpler tor struct for discovery info -pub fn get_port_lldp_info(port: &str) -> Result { - let lldp_json: String = get_lldp_port_info(port)?; - - // deserialize - let lldp_resp: LldpResponse = match serde_json::from_str(lldp_json.as_str()) { - Ok(x) => x, - Err(e) => { - warn!("Could not deserialize LLDP response {lldp_json}, {e}"); - return Err(DpuEnumerationError::Lldp(e.to_string())); - } - }; - - let mut lldp_info: LldpSwitchData = Default::default(); - // copy over useful fields - if let Some(lldp_data) = lldp_resp.lldp.interface.get(port) { - for (tor, tor_data) in lldp_data.chassis.iter() { - lldp_info.name = tor.to_string(); - lldp_info.id = format!("{}={}", tor_data.id.id_type, tor_data.id.value); - lldp_info.description = tor_data.descr.to_string(); - lldp_info.local_port = port.to_string(); - - // management_ip_address if missing we just replace it with empty list. - lldp_info.ip_address = tor_data - .management_ip_address - .iter() - .map(|ip| ip.to_string()) - .collect(); - } - lldp_info.remote_port = - format!("{}={}", lldp_data.port.id.id_type, lldp_data.port.id.value); - } else { - warn!("Malformed LLDP JSON response, port not found"); - return Err(DpuEnumerationError::Lldp( - "LLDP: port not found".to_string(), - )); - } - - Ok(lldp_info) -} - fn get_flint_query() -> Result { if cfg!(test) { const TEST_DATA: &str = "test/flint_query.txt"; @@ -312,9 +198,10 @@ pub fn get_dpu_info() -> Result { wait_until_all_ports_available(); for port in LLDP_PORTS.iter() { match get_port_lldp_info(port) { - Ok(lldp_info) => { + Ok(Some(lldp_info)) => { switches.push(lldp_info); } + Ok(None) => {} Err(_e) => {} } } @@ -334,8 +221,7 @@ pub fn get_dpu_info() -> Result { #[cfg(test)] mod tests { - use carbide_test_support::Outcome::*; - use carbide_test_support::{scenarios, value_scenarios}; + use carbide_test_support::value_scenarios; use crate::hardware_enumeration::dpu; @@ -379,85 +265,4 @@ mod tests { } ); } - - // `get_port_lldp_info` reads the `test/lldp_query.json` fixture (in `cfg(test)` - // it ignores the live `lldpcli` command) and then looks the requested port up - // in `lldp.interface`. The lookup, the `OneOrMany` mgmt-ip flattening, and the - // tor/port field formatting are all pure given that fixture, so we pin the - // facts each known port produces and the not-found rejection path. - // - // The yielded value for each row is `(first_ip, ip_count, name, remote_port)`. - #[test] - fn get_port_lldp_info_translates_fixture() { - scenarios!( - run = |port| { - let info = dpu::get_port_lldp_info(port).map_err(drop)?; - let first_ip = info.ip_address.first().cloned().unwrap_or_default(); - Ok::<_, ()>((first_ip, info.ip_address.len(), info.name, info.remote_port)) - }; - "oob_net0: single (scalar) mgmt-ip" { - "oob_net0" => Yields(( - "10.180.253.66".to_string(), - 1, - "RNO1-M03-B17-IPMI-01".to_string(), - "ifname=swp7".to_string(), - )), - } - - "p0: array mgmt-ip keeps first (v4) and counts both" { - "p0" => Yields(( - "10.180.253.67".to_string(), - 2, - "RNO1-M03-B17-IPMI-01".to_string(), - "ifname=swp7".to_string(), - )), - } - - "p1: distinct array mgmt-ip, first is v4" { - "p1" => Yields(( - "10.180.253.66".to_string(), - 2, - "RNO1-M03-B17-IPMI-01".to_string(), - "ifname=swp7".to_string(), - )), - } - - "unknown port: not present in the fixture interface map" { - "p99" => Fails, - } - - "empty port name: also absent" { - "" => Fails, - } - ); - } - - // The tor-id and description formatting on the resolved switch is its own - // contract: `id` is rendered `"{id_type}={value}"` and `description`/`local_port` - // are copied verbatim. Token-contains keeps this robust to fixture churn. - // - // The yielded value is whether every expected token appears in the rendered field. - #[test] - fn get_port_lldp_info_formats_fields() { - scenarios!( - run = |(port, tokens): (&str, &[&str])| { - let info = dpu::get_port_lldp_info(port).map_err(drop)?; - // Concatenate the formatted fields this row may inspect; every token - // must appear somewhere across id / description / local_port. - let haystack = format!("{} {} {}", info.id, info.description, info.local_port); - Ok::<_, ()>(tokens.iter().all(|t| haystack.contains(t))) - }; - "id renders mac type=value for oob_net0" { - ("oob_net0", &["mac=", "0c:29:ef:d9:1c:20"][..]) => Yields(true), - } - - "description carried verbatim for p0" { - ("p0", &["Cumulus Linux", "DELL S3048ON"][..]) => Yields(true), - } - - "local_port echoes the requested port" { - ("p1", &["p1"][..]) => Yields(true), - } - ); - } } diff --git a/crates/host-support/src/lib.rs b/crates/host-support/src/lib.rs index 3e14d587b9..2c73526d72 100644 --- a/crates/host-support/src/lib.rs +++ b/crates/host-support/src/lib.rs @@ -29,6 +29,10 @@ pub mod agent_config; pub mod dpa_cmds; #[cfg(feature = "linux-build")] pub mod hardware_enumeration; +#[cfg(feature = "linux-build")] +pub mod lldp_collector; +#[cfg(feature = "linux-build")] +pub mod lldp_reporter; pub mod registration; static LOG_SETUP: Once = Once::new(); diff --git a/crates/host-support/src/lldp_collector.rs b/crates/host-support/src/lldp_collector.rs new file mode 100644 index 0000000000..7461c3c833 --- /dev/null +++ b/crates/host-support/src/lldp_collector.rs @@ -0,0 +1,303 @@ +use crate::hardware_enumeration::PCI_SUBCLASS; +use ::rpc::machine_discovery as rpc_discovery; +use carbide_utils::cmd::Cmd; +use libudev::Device; +use serde::{Deserialize, Serialize}; +use serde_with::{OneOrMany, serde_as}; +use std::collections::HashMap; +use std::fs; +use std::net::IpAddr; +use tracing::{debug, warn}; + +#[derive(thiserror::Error, Debug)] +pub enum LldpCollectorError { + #[error("Udev failed with error: {0}")] + Udev(#[from] libudev::Error), + #[error("LLDP collection failed reading '{0}': {1}")] + Read(&'static str, String), + #[error("LLDP error: {0}")] + Lldp(String), +} + +pub type LldpCollectorResult = Result; + +#[derive(Debug, Deserialize, Serialize, Clone)] +pub struct LldpCapabilityData { + #[serde(rename = "type")] + pub capability_type: String, + pub enabled: bool, +} + +#[derive(Debug, Deserialize, Serialize, Clone)] +pub struct LldpIdData { + #[serde(rename = "type")] + pub id_type: String, + pub value: String, +} + +#[serde_as] +#[derive(Debug, Deserialize, Serialize, Clone)] +pub struct LldpChassisData { + pub id: LldpIdData, + pub descr: String, + #[serde(rename = "mgmt-ip", default)] + #[serde_as(as = "OneOrMany<_>")] + pub management_ip_address: Vec, // we get an array with ipv4 and ipv6 addresses + #[serde(default)] + pub capability: Vec, +} + +#[derive(Debug, Deserialize, Serialize, Clone)] +pub struct LldpPortData { + pub id: LldpIdData, + pub descr: Option, + pub ttl: String, +} + +#[derive(Debug, Deserialize, Serialize, Clone)] +pub struct LldpQueryData { + pub age: String, + pub chassis: HashMap, // the key in this hash is the tor name + pub port: LldpPortData, +} + +#[derive(Debug, Deserialize, Serialize, Clone)] +pub struct LldpInterface { + pub interface: HashMap, // the key in this hash is the port #, eg. p0 +} + +#[derive(Debug, Deserialize, Serialize, Clone)] +pub struct LldpResponse { + pub lldp: LldpInterface, +} + +/// Lightweight per-interface LLDP snapshot for periodic reporting (host + DPU). +/// +/// Enumerates Ethernet net interfaces and returns `(local_mac, neighbor)` pairs +/// for every interface that currently has an LLDP neighbor. Interfaces without a +/// neighbor (or when lldpd is unavailable) are omitted, (todo loopback omit) so a full-snapshot report +/// lets the server reconcile (drop) interfaces whose neighbor disappeared. +/// +/// This is intentionally cheap so it can run on a short interval. +pub fn collect_lldp_neighbors() -> LldpCollectorResult> +{ + let context = libudev::Context::new()?; + let mut enumerator = libudev::Enumerator::new(&context)?; + enumerator.match_subsystem("net")?; + + let mut out = Vec::new(); + for device in enumerator.scan_devices()? { + let Ok(pci_subclass) = + crate::hardware_enumeration::convert_property_to_string(PCI_SUBCLASS, "", &device) + else { + continue; + }; + if !pci_subclass.eq_ignore_ascii_case("Ethernet controller") { + continue; + } + let Some(ifname) = device.sysname().and_then(|s| s.to_str()) else { + continue; + }; + let Some(mac) = read_interface_mac(ifname) else { + continue; + }; + if let Some(lldp) = lldp_for_device(&device) { + out.push((mac, lldp)); + } + } + Ok(out) +} + +/// Collect the LLDP neighbors visible on this host and print them. Purely local +/// interrogation for troubleshooting: no API integration, mirrors what +/// `report_lldp_neighbors` would send to carbide-api. +pub fn print_lldp_neighbors() -> Result<(), eyre::Report> { + let pairs = collect_lldp_neighbors().map_err(|e| eyre::eyre!("lldp collect: {e}"))?; + + if pairs.is_empty() { + println!("No LLDP neighbors found."); + return Ok(()); + } + + for (mac_address, lldp) in pairs { + println!("{mac_address}: {lldp:#?}"); + } + Ok(()) +} + +fn read_interface_mac(ifname: &str) -> Option { + let mac = fs::read_to_string(format!("/sys/class/net/{ifname}/address")).ok()?; + let mac = mac.trim(); + if mac.is_empty() { + return None; + } + Some(mac.to_string()) +} + +/// Best-effort LLDP neighbor for a net device's kernel interface name. +/// +/// Returns `None` when the device has no kernel name, `lldpd`/`lldpcli` is +/// unavailable, or no neighbor is advertised on the port. Never blocks (a single +/// `lldpcli` query per interface, unlike `wait_until_all_ports_available`). +fn lldp_for_device(device: &Device) -> Option { + let ifname = device.sysname()?.to_str()?; + match get_port_lldp_info(ifname) { + Ok(lldp) => lldp, + Err(e) => { + tracing::debug!(ifname, "no LLDP neighbor: {e}"); + None + } + } +} + +/// Get LLDP port info. +pub fn get_lldp_port_info(port: &str) -> LldpCollectorResult { + if cfg!(test) { + const TEST_DATA: &str = "test/lldp_query.json"; + std::fs::read_to_string(TEST_DATA).map_err(|e| { + warn!("Could not read LLDP json: {e}"); + LldpCollectorError::Read(TEST_DATA, e.to_string()) + }) + } else { + let lldp_cmd = format!("lldpcli -f json show neighbors ports {port}"); + Cmd::new("bash") + .args(vec!["-c", lldp_cmd.as_str()]) + .output() + .map_err(|e| { + warn!("Could not discover LLDP peer for {port}, {e}"); + LldpCollectorError::Lldp(e.to_string()) + }) + } +} + +/// query lldp info for a port, translate to simpler tor struct for discovery info. +/// +/// Returns `Ok(None)` when the port advertises no LLDP neighbor. +pub fn get_port_lldp_info( + port: &str, +) -> LldpCollectorResult> { + let lldp_json: String = get_lldp_port_info(port)?; + + // deserialize + let lldp_resp: LldpResponse = match serde_json::from_str(lldp_json.as_str()) { + Ok(x) => x, + Err(e) => { + warn!("Could not deserialize LLDP response {lldp_json}, {e}"); + return Err(LldpCollectorError::Lldp(e.to_string())); + } + }; + + let mut lldp_info: rpc_discovery::LldpSwitchData = Default::default(); + // copy over useful fields + if let Some(lldp_data) = lldp_resp.lldp.interface.get(port) { + for (tor, tor_data) in lldp_data.chassis.iter() { + lldp_info.name = tor.to_string(); + lldp_info.id = format!("{}={}", tor_data.id.id_type, tor_data.id.value); + lldp_info.description = tor_data.descr.to_string(); + lldp_info.local_port = port.to_string(); + + // management_ip_address if missing we just replace it with empty list. + lldp_info.ip_address = tor_data + .management_ip_address + .iter() + .map(|ip| ip.to_string()) + .collect(); + } + lldp_info.remote_port = + format!("{}={}", lldp_data.port.id.id_type, lldp_data.port.id.value); + } else { + debug!("No LLDP switch data for port {port}"); + return Ok(None); + } + + Ok(Some(lldp_info)) +} + +#[cfg(test)] +mod tests { + use carbide_test_support::Outcome::*; + use carbide_test_support::scenarios; + + use super::get_port_lldp_info; + + // `get_port_lldp_info` reads the `test/lldp_query.json` fixture (in `cfg(test)` + // it ignores the live `lldpcli` command) and then looks the requested port up + // in `lldp.interface`. The lookup, the `OneOrMany` mgmt-ip flattening, and the + // tor/port field formatting are all pure given that fixture, so we pin the + // facts each known port produces and the not-found rejection path. + // + // The yielded value for each row is `(first_ip, ip_count, name, remote_port)`. + #[test] + fn get_port_lldp_info_translates_fixture() { + scenarios!( + run = |port| { + let info = get_port_lldp_info(port).map_err(drop)?.ok_or(())?; + let first_ip = info.ip_address.first().cloned().unwrap_or_default(); + Ok::<_, ()>((first_ip, info.ip_address.len(), info.name, info.remote_port)) + }; + "oob_net0: single (scalar) mgmt-ip" { + "oob_net0" => Yields(( + "10.180.253.66".to_string(), + 1, + "RNO1-M03-B17-IPMI-01".to_string(), + "ifname=swp7".to_string(), + )), + } + + "p0: array mgmt-ip keeps first (v4) and counts both" { + "p0" => Yields(( + "10.180.253.67".to_string(), + 2, + "RNO1-M03-B17-IPMI-01".to_string(), + "ifname=swp7".to_string(), + )), + } + + "p1: distinct array mgmt-ip, first is v4" { + "p1" => Yields(( + "10.180.253.66".to_string(), + 2, + "RNO1-M03-B17-IPMI-01".to_string(), + "ifname=swp7".to_string(), + )), + } + + "unknown port: not present in the fixture interface map" { + "p99" => Fails, + } + + "empty port name: also absent" { + "" => Fails, + } + ); + } + + // The tor-id and description formatting on the resolved switch is its own + // contract: `id` is rendered `"{id_type}={value}"` and `description`/`local_port` + // are copied verbatim. Token-contains keeps this robust to fixture churn. + // + // The yielded value is whether every expected token appears in the rendered field. + #[test] + fn get_port_lldp_info_formats_fields() { + scenarios!( + run = |(port, tokens): (&str, &[&str])| { + let info = get_port_lldp_info(port).map_err(drop)?.ok_or(())?; + // Concatenate the formatted fields this row may inspect; every token + // must appear somewhere across id / description / local_port. + let haystack = format!("{} {} {}", info.id, info.description, info.local_port); + Ok::<_, ()>(tokens.iter().all(|t| haystack.contains(t))) + }; + "id renders mac type=value for oob_net0" { + ("oob_net0", &["mac=", "0c:29:ef:d9:1c:20"][..]) => Yields(true), + } + + "description carried verbatim for p0" { + ("p0", &["Cumulus Linux", "DELL S3048ON"][..]) => Yields(true), + } + + "local_port echoes the requested port" { + ("p1", &["p1"][..]) => Yields(true), + } + ); + } +} diff --git a/crates/host-support/src/lldp_reporter.rs b/crates/host-support/src/lldp_reporter.rs new file mode 100644 index 0000000000..e69de29bb2 From 4c345a87c0f9bce6eaddaa04a10dc5319bba506e Mon Sep 17 00:00:00 2001 From: Anatolii Kurotych Date: Wed, 8 Jul 2026 20:16:15 +0300 Subject: [PATCH 02/10] refactor(host-support): drop LLDP handling from DPU enumeration Signed-off-by: Anatolii Kurotych --- .../src/hardware_enumeration/dpu.rs | 121 +----------------- 1 file changed, 4 insertions(+), 117 deletions(-) diff --git a/crates/host-support/src/hardware_enumeration/dpu.rs b/crates/host-support/src/hardware_enumeration/dpu.rs index fb8df7c851..5f2e26d019 100644 --- a/crates/host-support/src/hardware_enumeration/dpu.rs +++ b/crates/host-support/src/hardware_enumeration/dpu.rs @@ -15,17 +15,9 @@ * limitations under the License. */ -use std::thread::sleep; -use std::time::{Duration, Instant}; - use carbide_utils::cmd::{Cmd, CmdError}; use regex::Regex; -use rpc::machine_discovery::{DpuData, LldpSwitchData}; -use tracing::{debug, warn}; - -use crate::lldp_collector::get_port_lldp_info; - -const LLDP_PORTS: &[&str] = &["p0", "p1", "oob_net0"]; +use rpc::machine_discovery::DpuData; #[derive(thiserror::Error, Debug)] pub enum DpuEnumerationError { @@ -37,50 +29,6 @@ pub enum DpuEnumerationError { Cmd(#[from] CmdError), #[error("DPU enumeration failed reading '{0}': {1}")] Read(&'static str, String), - #[error("LLDP error: {0}")] - Lldp(String), -} - -pub fn wait_until_all_ports_available() { - const MAX_TIMEOUT: Duration = Duration::from_secs(60 * 5); - const RETRY_TIME: Duration = Duration::from_secs(5); - let now = Instant::now(); - let mut ports_read = vec![]; - - for port in LLDP_PORTS.iter() { - while now.elapsed() <= MAX_TIMEOUT { - match get_port_lldp_info(port) { - Ok(_) => { - ports_read.push(port); - break; - } - Err(_e) => { - warn!(port, "Port is not available yet."); - sleep(RETRY_TIME); - } - } - } - } - - debug!("lldp: Ports {:?} are read successfully.", ports_read); -} - -// LLDP was broken in multiple forge versions. It was fixed in HBN 2.1/ doca 2.6, as per -// https://redmine.mellanox.com/issues/3753899 -// 2.1 aligns with XX.40.1000 firmwware, so if the middle section of firmware is equal or greater -// than 40, then LLDP should work. - -// LLDP is not fully configured on sites and causes issues. It makes the dpu agent hang at startup. -// For now this will return false until a better fix is worked out. -pub fn is_lldp_working(_fw_version: &str) -> bool { - /* - fw_version - .split('.') - .nth(1) // second chunk is what we care about - .and_then(|m| m.parse::().ok()) // turn it into a number - .is_some_and(|n| n >= 40) // ensure its greater than or equal to 2.1 (40) - */ - false } fn get_flint_query() -> Result { @@ -192,21 +140,6 @@ pub fn get_dpu_info() -> Result { factory_mac.insert(14, ':'); } - let mut switches: Vec = vec![]; - - if is_lldp_working(&fw_ver[0]) { - wait_until_all_ports_available(); - for port in LLDP_PORTS.iter() { - match get_port_lldp_info(port) { - Ok(Some(lldp_info)) => { - switches.push(lldp_info); - } - Ok(None) => {} - Err(_e) => {} - } - } - } - let dpu_info = DpuData { part_number: part_number[0].clone(), part_description: device_description[0].clone(), @@ -214,55 +147,9 @@ pub fn get_dpu_info() -> Result { factory_mac_address: factory_mac, firmware_version: fw_ver[0].clone(), firmware_date: fw_date[0].clone(), - switches, + // Left empty here; LLDP neighbors are collected and reported separately + // by the lldp_collector/lldp_reporter. + switches: vec![], }; Ok(dpu_info) } - -#[cfg(test)] -mod tests { - use carbide_test_support::value_scenarios; - - use crate::hardware_enumeration::dpu; - - // `is_lldp_working` is currently stubbed to always return `false` (the - // firmware-version parse is commented out pending a real LLDP fix). Every - // input -- valid versions, boundary `40`, and garbage -- must yield `false`. - #[test] - fn is_lldp_working_always_false() { - value_scenarios!( - run = dpu::is_lldp_working; - "below the 40 boundary" { - "xx.39.yyyy" => false, - } - - "at the 40 boundary" { - "xx.40.yyyy" => false, - } - - "above the 40 boundary" { - "xx.41.yyyy" => false, - } - - "non-numeric middle chunk" { - "xx.zz.yyyy" => false, - } - - "no dots at all" { - "junk" => false, - } - - "empty string" { - "" => false, - } - - "well-formed high version" { - "22.99.1000" => false, - } - - "single leading dot" { - ".40." => false, - } - ); - } -} From 0bebd41040853613ca2cf046671f75d022def046 Mon Sep 17 00:00:00 2001 From: Anatolii Kurotych Date: Thu, 9 Jul 2026 13:28:32 +0300 Subject: [PATCH 03/10] feat(host-support): parse lldpcli json0 and report every neighbor per port MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rework lldp collector to consume `lldpcli -f json0` instead of `-f json`. json0 emits a stable, uniform structure — every field is an array with fixed keys — whereas `-f json` makes the output structure data-dependent and fragile to parse. Add split id_type/id_value and remote_port_type/remote_port_value fields to LldpSwitchData; the combined id/remote_port strings are kept and marked deprecated for backward compatibility Signed-off-by: Anatolii Kurotych --- crates/host-support/src/lldp_collector.rs | 375 ++++++++++++---------- crates/host-support/src/lldp_reporter.rs | 1 + crates/host-support/test/lldp_query.json | 110 ------- crates/rpc/proto/machine_discovery.proto | 13 +- crates/rpc/src/model/hardware_info.rs | 6 + 5 files changed, 217 insertions(+), 288 deletions(-) delete mode 100644 crates/host-support/test/lldp_query.json diff --git a/crates/host-support/src/lldp_collector.rs b/crates/host-support/src/lldp_collector.rs index 7461c3c833..83513610a8 100644 --- a/crates/host-support/src/lldp_collector.rs +++ b/crates/host-support/src/lldp_collector.rs @@ -1,14 +1,13 @@ -use crate::hardware_enumeration::PCI_SUBCLASS; +use std::fs; + use ::rpc::machine_discovery as rpc_discovery; use carbide_utils::cmd::Cmd; use libudev::Device; use serde::{Deserialize, Serialize}; -use serde_with::{OneOrMany, serde_as}; -use std::collections::HashMap; -use std::fs; -use std::net::IpAddr; use tracing::{debug, warn}; +use crate::hardware_enumeration::PCI_SUBCLASS; + #[derive(thiserror::Error, Debug)] pub enum LldpCollectorError { #[error("Udev failed with error: {0}")] @@ -21,54 +20,65 @@ pub enum LldpCollectorError { pub type LldpCollectorResult = Result; +// `lldpcli -f json0` renders every field as a JSON array of objects: simple values +// become `{ "value": "..." }`, typed ids become `{ "type": "...", "value": "..." }`, +// and — crucially — each LLDP neighbor is emitted as its own `interface` array entry +// (even multiple neighbors sharing one physical port), which (the older) map-keyed +// `-f json` format could not represent. + #[derive(Debug, Deserialize, Serialize, Clone)] -pub struct LldpCapabilityData { - #[serde(rename = "type")] - pub capability_type: String, - pub enabled: bool, +pub struct LldpValue { + pub value: String, } #[derive(Debug, Deserialize, Serialize, Clone)] -pub struct LldpIdData { +pub struct LldpId { #[serde(rename = "type")] pub id_type: String, pub value: String, } -#[serde_as] -#[derive(Debug, Deserialize, Serialize, Clone)] -pub struct LldpChassisData { - pub id: LldpIdData, - pub descr: String, - #[serde(rename = "mgmt-ip", default)] - #[serde_as(as = "OneOrMany<_>")] - pub management_ip_address: Vec, // we get an array with ipv4 and ipv6 addresses +#[derive(Debug, Deserialize, Serialize, Clone, Default)] +pub struct LldpChassis { #[serde(default)] - pub capability: Vec, + pub id: Vec, + #[serde(default)] + pub name: Vec, + #[serde(default)] + pub descr: Vec, + #[serde(rename = "mgmt-ip", default)] + pub mgmt_ip: Vec, } -#[derive(Debug, Deserialize, Serialize, Clone)] -pub struct LldpPortData { - pub id: LldpIdData, - pub descr: Option, - pub ttl: String, +#[derive(Debug, Deserialize, Serialize, Clone, Default)] +pub struct LldpPort { + #[serde(default)] + pub id: Vec, + #[serde(default)] + pub descr: Vec, + #[serde(default)] + pub ttl: Vec, } #[derive(Debug, Deserialize, Serialize, Clone)] -pub struct LldpQueryData { - pub age: String, - pub chassis: HashMap, // the key in this hash is the tor name - pub port: LldpPortData, +pub struct LldpInterfaceEntry { + pub name: String, // local interface name (host side) + #[serde(default)] + pub chassis: Vec, + #[serde(default)] + pub port: Vec, } -#[derive(Debug, Deserialize, Serialize, Clone)] -pub struct LldpInterface { - pub interface: HashMap, // the key in this hash is the port #, eg. p0 +#[derive(Debug, Deserialize, Serialize, Clone, Default)] +pub struct LldpRoot { + #[serde(default)] + pub interface: Vec, } -#[derive(Debug, Deserialize, Serialize, Clone)] +#[derive(Debug, Deserialize, Serialize, Clone, Default)] pub struct LldpResponse { - pub lldp: LldpInterface, + #[serde(default)] + pub lldp: Vec, } /// Lightweight per-interface LLDP snapshot for periodic reporting (host + DPU). @@ -101,9 +111,11 @@ pub fn collect_lldp_neighbors() -> LldpCollectorResult Option { Some(mac.to_string()) } -/// Best-effort LLDP neighbor for a net device's kernel interface name. +/// Best-effort LLDP neighbors for a net device's kernel interface name. /// -/// Returns `None` when the device has no kernel name, `lldpd`/`lldpcli` is -/// unavailable, or no neighbor is advertised on the port. Never blocks (a single -/// `lldpcli` query per interface, unlike `wait_until_all_ports_available`). -fn lldp_for_device(device: &Device) -> Option { - let ifname = device.sysname()?.to_str()?; +/// Returns an empty vec when the device has no kernel name, `lldpd`/`lldpcli` is +/// unavailable, or no neighbor is advertised on the port. A single physical port +/// may report several neighbors, hence a `Vec`. Never blocks (a single `lldpcli` +/// query per interface, unlike `wait_until_all_ports_available`). +fn lldp_for_device(device: &Device) -> Vec { + let Some(ifname) = device.sysname().and_then(|s| s.to_str()) else { + return Vec::new(); + }; match get_port_lldp_info(ifname) { Ok(lldp) => lldp, Err(e) => { tracing::debug!(ifname, "no LLDP neighbor: {e}"); - None + Vec::new() } } } -/// Get LLDP port info. +/// Get raw `lldpcli -f json0` output for a port. pub fn get_lldp_port_info(port: &str) -> LldpCollectorResult { - if cfg!(test) { - const TEST_DATA: &str = "test/lldp_query.json"; - std::fs::read_to_string(TEST_DATA).map_err(|e| { - warn!("Could not read LLDP json: {e}"); - LldpCollectorError::Read(TEST_DATA, e.to_string()) + let lldp_cmd = format!("lldpcli -f json0 show neighbors ports {port}"); + Cmd::new("bash") + .args(vec!["-c", lldp_cmd.as_str()]) + .output() + .map_err(|e| { + warn!("Could not discover LLDP peer for {port}, {e}"); + LldpCollectorError::Lldp(e.to_string()) }) - } else { - let lldp_cmd = format!("lldpcli -f json show neighbors ports {port}"); - Cmd::new("bash") - .args(vec!["-c", lldp_cmd.as_str()]) - .output() - .map_err(|e| { - warn!("Could not discover LLDP peer for {port}, {e}"); - LldpCollectorError::Lldp(e.to_string()) - }) - } } -/// query lldp info for a port, translate to simpler tor struct for discovery info. +/// query lldp info for a port, translate to simpler tor structs for discovery info. /// -/// Returns `Ok(None)` when the port advertises no LLDP neighbor. -pub fn get_port_lldp_info( - port: &str, -) -> LldpCollectorResult> { - let lldp_json: String = get_lldp_port_info(port)?; - - // deserialize - let lldp_resp: LldpResponse = match serde_json::from_str(lldp_json.as_str()) { - Ok(x) => x, - Err(e) => { - warn!("Could not deserialize LLDP response {lldp_json}, {e}"); - return Err(LldpCollectorError::Lldp(e.to_string())); - } - }; +/// Returns an empty vec when the port advertises no LLDP neighbor. +pub fn get_port_lldp_info(port: &str) -> LldpCollectorResult> { + parse_port_lldp(&get_lldp_port_info(port)?) +} - let mut lldp_info: rpc_discovery::LldpSwitchData = Default::default(); - // copy over useful fields - if let Some(lldp_data) = lldp_resp.lldp.interface.get(port) { - for (tor, tor_data) in lldp_data.chassis.iter() { - lldp_info.name = tor.to_string(); - lldp_info.id = format!("{}={}", tor_data.id.id_type, tor_data.id.value); - lldp_info.description = tor_data.descr.to_string(); - lldp_info.local_port = port.to_string(); - - // management_ip_address if missing we just replace it with empty list. - lldp_info.ip_address = tor_data - .management_ip_address - .iter() - .map(|ip| ip.to_string()) - .collect(); - } - lldp_info.remote_port = - format!("{}={}", lldp_data.port.id.id_type, lldp_data.port.id.value); - } else { - debug!("No LLDP switch data for port {port}"); - return Ok(None); +/// Parse `lldpcli -f json0` output into one `LldpSwitchData` per neighbor. +/// +/// Each `lldp[].interface[]` entry is a distinct neighbor. Entries advertising no +/// chassis are skipped; missing id/description/mgmt-ip degrade to empty values. +fn parse_port_lldp(lldp_json: &str) -> LldpCollectorResult> { + let lldp_resp: LldpResponse = serde_json::from_str(lldp_json).map_err(|e| { + warn!("Could not deserialize LLDP response {lldp_json}, {e}"); + LldpCollectorError::Lldp(e.to_string()) + })?; + + let mut neighbors = Vec::new(); + for entry in lldp_resp.lldp.iter().flat_map(|root| root.interface.iter()) { + let Some(chassis) = entry.chassis.first() else { + debug!("No LLDP chassis data for port {}", entry.name); + continue; + }; + + let (id_type, id_value) = chassis + .id + .first() + .map(|id| (id.id_type.clone(), id.value.clone())) + .unwrap_or_default(); + let (remote_port_type, remote_port_value) = entry + .port + .first() + .and_then(|port| port.id.first()) + .map(|id| (id.id_type.clone(), id.value.clone())) + .unwrap_or_default(); + + // The `deprecated` allow keeps the legacy combined `id`/`remote_port` + // strings populated for backward compatibility until consumers migrate + // to the split *_type/*_value fields. + #[allow(deprecated)] + neighbors.push(rpc_discovery::LldpSwitchData { + name: chassis + .name + .first() + .map(|n| n.value.clone()) + .unwrap_or_default(), + id: format!("{id_type}={id_value}"), + description: chassis + .descr + .first() + .map(|d| d.value.clone()) + .unwrap_or_default(), + local_port: entry.name.clone(), + ip_address: chassis.mgmt_ip.iter().map(|ip| ip.value.clone()).collect(), + remote_port: format!("{remote_port_type}={remote_port_value}"), + id_type, + id_value, + remote_port_type, + remote_port_value, + }); } - Ok(Some(lldp_info)) + Ok(neighbors) } #[cfg(test)] mod tests { - use carbide_test_support::Outcome::*; - use carbide_test_support::scenarios; - - use super::get_port_lldp_info; - - // `get_port_lldp_info` reads the `test/lldp_query.json` fixture (in `cfg(test)` - // it ignores the live `lldpcli` command) and then looks the requested port up - // in `lldp.interface`. The lookup, the `OneOrMany` mgmt-ip flattening, and the - // tor/port field formatting are all pure given that fixture, so we pin the - // facts each known port produces and the not-found rejection path. - // - // The yielded value for each row is `(first_ip, ip_count, name, remote_port)`. + use super::parse_port_lldp; + + // Three LLDP neighbors on a single physical port (`vlldp`). `-f json0` emits + // each as its own `interface` array entry, so `parse_port_lldp` must yield one + // `LldpSwitchData` per entry, in order, with no mgmt-ip or description. + const MULTI_NEIGHBOR: &str = r#"{ + "lldp": [ + { "interface": [ + { "name": "vlldp", "chassis": [ + { "id": [{"type":"local","value":"host-00"}], "name": [{"value":"neighbor-00"}] }], + "port": [{ "id": [{"type":"ifname","value":"port-00"}], "ttl": [{"value":"120"}] }] }, + { "name": "vlldp", "chassis": [ + { "id": [{"type":"local","value":"host-01"}], "name": [{"value":"neighbor-01"}] }], + "port": [{ "id": [{"type":"ifname","value":"port-01"}], "ttl": [{"value":"120"}] }] }, + { "name": "vlldp", "chassis": [ + { "id": [{"type":"local","value":"host-02"}], "name": [{"value":"neighbor-02"}] }], + "port": [{ "id": [{"type":"ifname","value":"port-02"}], "ttl": [{"value":"120"}] }] } + ] } + ] + }"#; + + // Single neighbor carrying two mgmt-ips (v4 + v6) and a description — the shape + // of a real `lldpcli -f json0 show neighbors ports p0`. + const SINGLE_NEIGHBOR: &str = r#"{ + "lldp": [ + { "interface": [ + { "name": "p0", "chassis": [ + { "id": [{"type":"mac","value":"00:11:22:33:44:55"}], + "name": [{"value":"example-switch-01"}], + "descr": [{"value":"Cumulus Linux version 5.11.1 running on Mellanox switch"}], + "mgmt-ip": [{"value":"192.0.2.10"},{"value":"2001:db8::10"}] }], + "port": [{ "id": [{"type":"ifname","value":"swp2"}], "ttl": [{"value":"120"}] }] } + ] } + ] + }"#; + #[test] - fn get_port_lldp_info_translates_fixture() { - scenarios!( - run = |port| { - let info = get_port_lldp_info(port).map_err(drop)?.ok_or(())?; - let first_ip = info.ip_address.first().cloned().unwrap_or_default(); - Ok::<_, ()>((first_ip, info.ip_address.len(), info.name, info.remote_port)) - }; - "oob_net0: single (scalar) mgmt-ip" { - "oob_net0" => Yields(( - "10.180.253.66".to_string(), - 1, - "RNO1-M03-B17-IPMI-01".to_string(), - "ifname=swp7".to_string(), - )), - } - - "p0: array mgmt-ip keeps first (v4) and counts both" { - "p0" => Yields(( - "10.180.253.67".to_string(), - 2, - "RNO1-M03-B17-IPMI-01".to_string(), - "ifname=swp7".to_string(), - )), - } - - "p1: distinct array mgmt-ip, first is v4" { - "p1" => Yields(( - "10.180.253.66".to_string(), - 2, - "RNO1-M03-B17-IPMI-01".to_string(), - "ifname=swp7".to_string(), - )), - } - - "unknown port: not present in the fixture interface map" { - "p99" => Fails, - } - - "empty port name: also absent" { - "" => Fails, - } - ); + #[allow(deprecated)] + fn parses_multiple_neighbors_on_one_port() { + let neighbors = parse_port_lldp(MULTI_NEIGHBOR).expect("parse"); + assert_eq!(neighbors.len(), 3); + for (i, n) in neighbors.iter().enumerate() { + assert_eq!(n.name, format!("neighbor-0{i}")); + // split fields plus the legacy combined strings + assert_eq!(n.id_type, "local"); + assert_eq!(n.id_value, format!("host-0{i}")); + assert_eq!(n.id, format!("local=host-0{i}")); + assert_eq!(n.remote_port_type, "ifname"); + assert_eq!(n.remote_port_value, format!("port-0{i}")); + assert_eq!(n.remote_port, format!("ifname=port-0{i}")); + assert_eq!(n.local_port, "vlldp"); + assert!(n.ip_address.is_empty()); + assert!(n.description.is_empty()); + } } - // The tor-id and description formatting on the resolved switch is its own - // contract: `id` is rendered `"{id_type}={value}"` and `description`/`local_port` - // are copied verbatim. Token-contains keeps this robust to fixture churn. - // - // The yielded value is whether every expected token appears in the rendered field. #[test] - fn get_port_lldp_info_formats_fields() { - scenarios!( - run = |(port, tokens): (&str, &[&str])| { - let info = get_port_lldp_info(port).map_err(drop)?.ok_or(())?; - // Concatenate the formatted fields this row may inspect; every token - // must appear somewhere across id / description / local_port. - let haystack = format!("{} {} {}", info.id, info.description, info.local_port); - Ok::<_, ()>(tokens.iter().all(|t| haystack.contains(t))) - }; - "id renders mac type=value for oob_net0" { - ("oob_net0", &["mac=", "0c:29:ef:d9:1c:20"][..]) => Yields(true), - } - - "description carried verbatim for p0" { - ("p0", &["Cumulus Linux", "DELL S3048ON"][..]) => Yields(true), - } - - "local_port echoes the requested port" { - ("p1", &["p1"][..]) => Yields(true), - } - ); + #[allow(deprecated)] + fn parses_single_neighbor_with_mgmt_ips() { + let neighbors = parse_port_lldp(SINGLE_NEIGHBOR).expect("parse"); + assert_eq!(neighbors.len(), 1); + let n = &neighbors[0]; + assert_eq!(n.name, "example-switch-01"); + assert_eq!(n.id_type, "mac"); + assert_eq!(n.id_value, "00:11:22:33:44:55"); + assert_eq!(n.id, "mac=00:11:22:33:44:55"); + assert_eq!(n.local_port, "p0"); + assert_eq!(n.remote_port_type, "ifname"); + assert_eq!(n.remote_port_value, "swp2"); + assert_eq!(n.remote_port, "ifname=swp2"); + assert_eq!(n.ip_address, vec!["192.0.2.10", "2001:db8::10"]); + assert!(n.description.contains("Cumulus Linux")); + } + + #[test] + fn parses_no_neighbors_as_empty() { + let neighbors = parse_port_lldp(r#"{"lldp":[{"interface":[]}]}"#).expect("parse"); + assert!(neighbors.is_empty()); } } diff --git a/crates/host-support/src/lldp_reporter.rs b/crates/host-support/src/lldp_reporter.rs index e69de29bb2..8b13789179 100644 --- a/crates/host-support/src/lldp_reporter.rs +++ b/crates/host-support/src/lldp_reporter.rs @@ -0,0 +1 @@ + diff --git a/crates/host-support/test/lldp_query.json b/crates/host-support/test/lldp_query.json deleted file mode 100644 index f4babceb89..0000000000 --- a/crates/host-support/test/lldp_query.json +++ /dev/null @@ -1,110 +0,0 @@ -{ - "lldp": { - "interface": { - "oob_net0": { - "via": "LLDP", - "rid": "4", - "age": "1 day, 03:21:35", - "chassis": { - "RNO1-M03-B17-IPMI-01": { - "id": { - "type": "mac", - "value": "0c:29:ef:d9:1c:20" - }, - "descr": "Cumulus Linux version 4.3.0 running on DELL S3048ON", - "mgmt-ip": "10.180.253.66", - "capability": [ - { - "type": "Bridge", - "enabled": true - }, - { - "type": "Router", - "enabled": true - } - ] - } - }, - "port": { - "id": { - "type": "ifname", - "value": "swp7" - }, - "ttl": "120" - } - }, - "p0": { - "via": "LLDP", - "rid": "4", - "age": "1 day, 03:21:35", - "chassis": { - "RNO1-M03-B17-IPMI-01": { - "id": { - "type": "mac", - "value": "0c:29:ef:d9:1c:21" - }, - "descr": "Cumulus Linux version 4.3.0 running on DELL S3048ON", - "mgmt-ip": [ - "10.180.253.67", - "fe80::e29:efef:fed9:1c20" - ], - "capability": [ - { - "type": "Bridge", - "enabled": true - }, - { - "type": "Router", - "enabled": true - } - ] - } - }, - "port": { - "id": { - "type": "ifname", - "value": "swp7" - }, - "descr": "RNO1-M03-B17-CPU-10:B17:15:DPU/BMC", - "ttl": "120" - } - }, - "p1": { - "via": "LLDP", - "rid": "4", - "age": "1 day, 03:21:35", - "chassis": { - "RNO1-M03-B17-IPMI-01": { - "id": { - "type": "mac", - "value": "0c:29:ef:e9:1c:20" - }, - "descr": "Cumulus Linux version 4.3.0 running on DELL S3048ON", - "mgmt-ip": [ - "10.180.253.66", - "fe80::e29:efff:fed0:1c20" - ], - "capability": [ - { - "type": "Bridge", - "enabled": true - }, - { - "type": "Router", - "enabled": true - } - ] - } - }, - "port": { - "id": { - "type": "ifname", - "value": "swp7" - }, - "descr": "RNO1-M03-B17-CPU-10:B17:15:DPU/BMC", - "ttl": "120" - } - } - } - } -} diff --git a/crates/rpc/proto/machine_discovery.proto b/crates/rpc/proto/machine_discovery.proto index 56964aca33..07683587b9 100644 --- a/crates/rpc/proto/machine_discovery.proto +++ b/crates/rpc/proto/machine_discovery.proto @@ -136,11 +136,20 @@ message PciDeviceProperties{ message LldpSwitchData { string name = 1; - string id = 2; + // Deprecated: chassis id rendered as "=". Use id_type/id_value. + string id = 2 [deprecated = true]; string description = 3; string local_port = 4; repeated string ip_address = 5; - string remote_port = 6; + // Deprecated: remote port rendered as "=". + // Use remote_port_type/remote_port_value. + string remote_port = 6 [deprecated = true]; + // Chassis id split into its LLDP subtype (e.g. "mac", "local") and value. + string id_type = 7; + string id_value = 8; + // Remote port id split into its LLDP subtype (e.g. "ifname") and value. + string remote_port_type = 9; + string remote_port_value = 10; } message DpuData { diff --git a/crates/rpc/src/model/hardware_info.rs b/crates/rpc/src/model/hardware_info.rs index 33c3dd1384..de268fb038 100644 --- a/crates/rpc/src/model/hardware_info.rs +++ b/crates/rpc/src/model/hardware_info.rs @@ -178,6 +178,8 @@ impl TryFrom for LldpSwitchData { type Error = RpcDataConversionError; fn try_from(data: rpc::machine_discovery::LldpSwitchData) -> Result { + // Reads the legacy combined `id`/`remote_port` proto fields (deprecated). + #[allow(deprecated)] Ok(Self { name: data.name, id: data.id, @@ -200,6 +202,9 @@ impl TryFrom for rpc::machine_discovery::LldpSwitchData { type Error = RpcDataConversionError; fn try_from(data: LldpSwitchData) -> Result { + // The api-model type carries only the legacy combined `id`/`remote_port`; + // the split *_type/*_value proto fields default to empty on this path. + #[allow(deprecated)] Ok(Self { name: data.name, id: data.id, @@ -211,6 +216,7 @@ impl TryFrom for rpc::machine_discovery::LldpSwitchData { .map(|ip| ip.to_string()) .collect(), remote_port: data.remote_port, + ..Default::default() }) } } From d723a84fdc228a9f66980f3f3d2eaf9c4d028dfd Mon Sep 17 00:00:00 2001 From: Anatolii Kurotych Date: Mon, 13 Jul 2026 11:29:19 +0300 Subject: [PATCH 04/10] feat(host-support): drop self-loopback LLDP neighbors by chassis id Signed-off-by: Anatolii Kurotych --- crates/host-support/src/lldp_collector.rs | 116 +++++++++++++++++++++- 1 file changed, 113 insertions(+), 3 deletions(-) diff --git a/crates/host-support/src/lldp_collector.rs b/crates/host-support/src/lldp_collector.rs index 83513610a8..e2538a7bef 100644 --- a/crates/host-support/src/lldp_collector.rs +++ b/crates/host-support/src/lldp_collector.rs @@ -31,7 +31,7 @@ pub struct LldpValue { pub value: String, } -#[derive(Debug, Deserialize, Serialize, Clone)] +#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Eq)] pub struct LldpId { #[serde(rename = "type")] pub id_type: String, @@ -85,9 +85,15 @@ pub struct LldpResponse { /// /// Enumerates Ethernet net interfaces and returns `(local_mac, neighbor)` pairs /// for every interface that currently has an LLDP neighbor. Interfaces without a -/// neighbor (or when lldpd is unavailable) are omitted, (todo loopback omit) so a full-snapshot report +/// neighbor (or when lldpd is unavailable) are omitted, so a full-snapshot report /// lets the server reconcile (drop) interfaces whose neighbor disappeared. /// +/// Neighbors advertising our own chassis id are discarded: on DPUs, e-switch +/// representor pairs loop LLDP frames back through the embedded switch, so the +/// box would otherwise report itself as its own neighbor. Comparing chassis ids +/// (rather than matching interface names) works regardless of NIC vendor, +/// naming convention, or host type. +/// /// This is intentionally cheap so it can run on a short interval. pub fn collect_lldp_neighbors() -> LldpCollectorResult> { @@ -95,6 +101,8 @@ pub fn collect_lldp_neighbors() -> LldpCollectorResult LldpCollectorResult !is_self_loopback(lldp, own), + None => true, + }) .map(|lldp| (mac.clone(), lldp)), ); } Ok(out) } +/// True when a neighbor's chassis id (type + value) matches our own local +/// chassis id, i.e. the "neighbor" is this box itself via an internal loopback +/// (e.g. a DPU e-switch representor pair). +fn is_self_loopback(neighbor: &rpc_discovery::LldpSwitchData, own: &LldpId) -> bool { + let is_self = neighbor.id_type == own.id_type && neighbor.id_value == own.value; + if is_self { + debug!( + local_port = neighbor.local_port, + "dropping self-loopback LLDP neighbor" + ); + } + is_self +} + +/// Chassis id this host's lldpd advertises (`lldpcli -f json0 show chassis`). +/// +/// Best-effort: `None` when lldpd is unavailable or the output is malformed. +fn get_local_chassis_id() -> Option { + let out = Cmd::new("bash") + .args(vec!["-c", "lldpcli -f json0 show chassis"]) + .output() + .map_err(|e| warn!("Could not read local LLDP chassis: {e}")) + .ok()?; + parse_local_chassis_id(&out) +} + +/// Parse `lldpcli -f json0 show chassis` output down to the first chassis id. +fn parse_local_chassis_id(json: &str) -> Option { + #[derive(Deserialize, Default)] + struct LocalChassisEntry { + #[serde(default)] + chassis: Vec, + } + #[derive(Deserialize, Default)] + struct LocalChassisRoot { + #[serde(rename = "local-chassis", default)] + local_chassis: Vec, + } + + let root: LocalChassisRoot = serde_json::from_str(json) + .map_err(|e| warn!("Could not deserialize local LLDP chassis {json}, {e}")) + .ok()?; + root.local_chassis + .into_iter() + .flat_map(|entry| entry.chassis) + .find_map(|chassis| chassis.id.into_iter().next()) +} + /// Collect the LLDP neighbors visible on this host and print them. Purely local /// interrogation for troubleshooting: no API integration, mirrors what /// `report_lldp_neighbors` would send to carbide-api. @@ -244,7 +306,7 @@ fn parse_port_lldp(lldp_json: &str) -> LldpCollectorResult genuine external link + assert!(!is_self_loopback(&neighbor("mac", "24:8a:07:b4:41:aa"), &own)); + // same string value but different id type -> not self + assert!(!is_self_loopback( + &neighbor("local", "58:a2:e1:54:6f:ae"), + &own + )); + } } From 8aa0f870cc5486226ab860534f431e68047e0a9a Mon Sep 17 00:00:00 2001 From: Anatolii Kurotych Date: Mon, 13 Jul 2026 12:08:33 +0300 Subject: [PATCH 05/10] feat(agent,scout): add lldp-neighbors subcommand for local troubleshooting Signed-off-by: Anatolii Kurotych --- crates/agent/src/command_line.rs | 3 +++ crates/agent/src/lib.rs | 4 ++++ crates/agent/src/main.rs | 9 ++++++++- crates/host-support/src/lldp_collector.rs | 10 ++++++++-- crates/host-support/src/lldp_reporter.rs | 1 - crates/scout/src/cfg/command_line.rs | 2 ++ crates/scout/src/main.rs | 7 +++++++ 7 files changed, 32 insertions(+), 4 deletions(-) delete mode 100644 crates/host-support/src/lldp_reporter.rs diff --git a/crates/agent/src/command_line.rs b/crates/agent/src/command_line.rs index 2a7f388276..d5ca674571 100644 --- a/crates/agent/src/command_line.rs +++ b/crates/agent/src/command_line.rs @@ -58,6 +58,9 @@ pub enum AgentCommand { #[clap(about = "One-off health check")] Health, + #[clap(about = "Print LLDP neighbors visible on this host and exit")] + LldpNeighbors, + #[clap(about = "One-off network monitor")] Network(NetworkOptions), diff --git a/crates/agent/src/lib.rs b/crates/agent/src/lib.rs index d3b9a6a08b..90decc5de8 100644 --- a/crates/agent/src/lib.rs +++ b/crates/agent/src/lib.rs @@ -226,6 +226,10 @@ pub async fn start(cmdline: command_line::Options) -> eyre::Result<()> { println!("{}", serde_json::to_string_pretty(&health_report)?); } + Some(AgentCommand::LldpNeighbors) => { + carbide_host_support::lldp_collector::print_lldp_neighbors()?; + } + // One-off network monitor check. // dumps JSON-formatted peer DPU network reachability and latency status Some(AgentCommand::Network(options)) => { diff --git a/crates/agent/src/main.rs b/crates/agent/src/main.rs index c63c4d6cd4..2bb1292ba6 100644 --- a/crates/agent/src/main.rs +++ b/crates/agent/src/main.rs @@ -18,6 +18,13 @@ use std::time::Duration; fn main() -> eyre::Result<()> { + let options = agent::Options::load(); + + // Purely local interrogation for troubleshooting. + if matches!(options.cmd, Some(agent::AgentCommand::LldpNeighbors)) { + return carbide_host_support::lldp_collector::print_lldp_neighbors(); + } + carbide_host_support::init_logging("nico-dpu-agent")?; // We need a multi-threaded runtime since background threads will queue work @@ -26,7 +33,7 @@ fn main() -> eyre::Result<()> { let rt = tokio::runtime::Builder::new_multi_thread() .enable_all() .build()?; - rt.block_on(agent::start(agent::Options::load()))?; + rt.block_on(agent::start(options))?; rt.shutdown_timeout(Duration::from_secs(2)); Ok(()) } diff --git a/crates/host-support/src/lldp_collector.rs b/crates/host-support/src/lldp_collector.rs index e2538a7bef..589575ea9a 100644 --- a/crates/host-support/src/lldp_collector.rs +++ b/crates/host-support/src/lldp_collector.rs @@ -424,9 +424,15 @@ mod tests { ..Default::default() }; - assert!(is_self_loopback(&neighbor("mac", "58:a2:e1:54:6f:ae"), &own)); + assert!(is_self_loopback( + &neighbor("mac", "58:a2:e1:54:6f:ae"), + &own + )); // different chassis value -> genuine external link - assert!(!is_self_loopback(&neighbor("mac", "24:8a:07:b4:41:aa"), &own)); + assert!(!is_self_loopback( + &neighbor("mac", "24:8a:07:b4:41:aa"), + &own + )); // same string value but different id type -> not self assert!(!is_self_loopback( &neighbor("local", "58:a2:e1:54:6f:ae"), diff --git a/crates/host-support/src/lldp_reporter.rs b/crates/host-support/src/lldp_reporter.rs deleted file mode 100644 index 8b13789179..0000000000 --- a/crates/host-support/src/lldp_reporter.rs +++ /dev/null @@ -1 +0,0 @@ - diff --git a/crates/scout/src/cfg/command_line.rs b/crates/scout/src/cfg/command_line.rs index 9ef8c60f94..d8d81f224e 100644 --- a/crates/scout/src/cfg/command_line.rs +++ b/crates/scout/src/cfg/command_line.rs @@ -128,6 +128,8 @@ pub(crate) enum Command { MachineValidation(MachineValidation), #[clap(about = "Local Mellanox device management.")] Mlx(Mlx), + #[clap(about = "Print LLDP neighbors visible on this host and exit")] + LldpNeighbors, } #[derive(Parser, Clone)] diff --git a/crates/scout/src/main.rs b/crates/scout/src/main.rs index 10c334e89e..55b9023882 100644 --- a/crates/scout/src/main.rs +++ b/crates/scout/src/main.rs @@ -94,6 +94,11 @@ async fn main() -> Result<(), eyre::Report> { return Ok(()); } + // Purely local interrogation for troubleshooting. + if matches!(config.subcmd, Some(Command::LldpNeighbors)) { + return carbide_host_support::lldp_collector::print_lldp_neighbors(); + } + check_if_running_in_qemu().await; carbide_host_support::init_logging("nico-scout")?; @@ -308,6 +313,8 @@ async fn run_standalone(config: &Options) -> Result<(), eyre::Report> { // sure we match everything. Maybe this could // log something. Command::Mlx(_) => return Ok(()), + // Handled in main() before logging/API setup; kept for exhaustiveness. + Command::LldpNeighbors => return Ok(()), }; handle_action(action, &machine_id, machine_interface_id, config).await?; From 53ea9db785e79eaabe49a4097e95e6e2f3780b6b Mon Sep 17 00:00:00 2001 From: Anatolii Kurotych Date: Mon, 13 Jul 2026 12:59:25 +0300 Subject: [PATCH 06/10] feat(scout-os): Install and enable lldpd in scout os Signed-off-by: Anatolii Kurotych --- pxe/mkosi.profiles/scout-oss-aarch64/mkosi.conf | 1 + pxe/mkosi.profiles/scout-oss-aarch64/mkosi.postinst.chroot | 4 ++++ pxe/mkosi.profiles/scout-oss-x86_64/mkosi.conf | 1 + pxe/mkosi.profiles/scout-oss-x86_64/mkosi.postinst.chroot | 4 ++++ 4 files changed, 10 insertions(+) diff --git a/pxe/mkosi.profiles/scout-oss-aarch64/mkosi.conf b/pxe/mkosi.profiles/scout-oss-aarch64/mkosi.conf index f098285280..b3d3f01044 100644 --- a/pxe/mkosi.profiles/scout-oss-aarch64/mkosi.conf +++ b/pxe/mkosi.profiles/scout-oss-aarch64/mkosi.conf @@ -41,6 +41,7 @@ Packages= iputils-ping keyboard-configuration isc-dhcp-client + lldpd libargtable2-0 libsystemd0 libudev-dev diff --git a/pxe/mkosi.profiles/scout-oss-aarch64/mkosi.postinst.chroot b/pxe/mkosi.profiles/scout-oss-aarch64/mkosi.postinst.chroot index 73c0995ce4..f7012fb56d 100755 --- a/pxe/mkosi.profiles/scout-oss-aarch64/mkosi.postinst.chroot +++ b/pxe/mkosi.profiles/scout-oss-aarch64/mkosi.postinst.chroot @@ -28,6 +28,10 @@ apt-cache search nvidia-driver | grep "^nvidia-driver" | head -20 apt-get ${APT_SANDBOX_OPTS} -y install ${NV_PACKAGES} +# lldpd must be running for forge-scout to collect LLDP neighbors via lldpcli. +# Enable it explicitly rather than relying on the package postinst. +systemctl enable lldpd + # ============================================================================ # FORGE-SCOUT # ============================================================================ diff --git a/pxe/mkosi.profiles/scout-oss-x86_64/mkosi.conf b/pxe/mkosi.profiles/scout-oss-x86_64/mkosi.conf index 66335f2da2..e5217c88fa 100644 --- a/pxe/mkosi.profiles/scout-oss-x86_64/mkosi.conf +++ b/pxe/mkosi.profiles/scout-oss-x86_64/mkosi.conf @@ -40,6 +40,7 @@ Packages= iputils-ping keyboard-configuration isc-dhcp-client + lldpd libargtable2-0 libsystemd0 libudev-dev diff --git a/pxe/mkosi.profiles/scout-oss-x86_64/mkosi.postinst.chroot b/pxe/mkosi.profiles/scout-oss-x86_64/mkosi.postinst.chroot index 73c0995ce4..f7012fb56d 100755 --- a/pxe/mkosi.profiles/scout-oss-x86_64/mkosi.postinst.chroot +++ b/pxe/mkosi.profiles/scout-oss-x86_64/mkosi.postinst.chroot @@ -28,6 +28,10 @@ apt-cache search nvidia-driver | grep "^nvidia-driver" | head -20 apt-get ${APT_SANDBOX_OPTS} -y install ${NV_PACKAGES} +# lldpd must be running for forge-scout to collect LLDP neighbors via lldpcli. +# Enable it explicitly rather than relying on the package postinst. +systemctl enable lldpd + # ============================================================================ # FORGE-SCOUT # ============================================================================ From 6a6185cdb23c4c91aa076fa2117849365d09a846 Mon Sep 17 00:00:00 2001 From: Anatolii Kurotych Date: Mon, 13 Jul 2026 13:09:01 +0300 Subject: [PATCH 07/10] small fixes Signed-off-by: Anatolii Kurotych --- crates/host-support/src/hardware_enumeration/dpu.rs | 2 +- crates/host-support/src/lib.rs | 2 -- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/crates/host-support/src/hardware_enumeration/dpu.rs b/crates/host-support/src/hardware_enumeration/dpu.rs index 5f2e26d019..73dca4f614 100644 --- a/crates/host-support/src/hardware_enumeration/dpu.rs +++ b/crates/host-support/src/hardware_enumeration/dpu.rs @@ -148,7 +148,7 @@ pub fn get_dpu_info() -> Result { firmware_version: fw_ver[0].clone(), firmware_date: fw_date[0].clone(), // Left empty here; LLDP neighbors are collected and reported separately - // by the lldp_collector/lldp_reporter. + // by the lldp_collector (lldp_reporter will be done in next PRs). switches: vec![], }; Ok(dpu_info) diff --git a/crates/host-support/src/lib.rs b/crates/host-support/src/lib.rs index 2c73526d72..aa65c2123e 100644 --- a/crates/host-support/src/lib.rs +++ b/crates/host-support/src/lib.rs @@ -31,8 +31,6 @@ pub mod dpa_cmds; pub mod hardware_enumeration; #[cfg(feature = "linux-build")] pub mod lldp_collector; -#[cfg(feature = "linux-build")] -pub mod lldp_reporter; pub mod registration; static LOG_SETUP: Once = Once::new(); From 72b4b5ac3b7d83ec8d472e46e81f0c976e5f8161 Mon Sep 17 00:00:00 2001 From: Anatolii Kurotych Date: Mon, 13 Jul 2026 14:14:34 +0300 Subject: [PATCH 08/10] fix(host-support): invoke lldpcli directly instead of via bash -c Signed-off-by: Anatolii Kurotych --- crates/host-support/src/lldp_collector.rs | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/crates/host-support/src/lldp_collector.rs b/crates/host-support/src/lldp_collector.rs index 589575ea9a..3da7d25c13 100644 --- a/crates/host-support/src/lldp_collector.rs +++ b/crates/host-support/src/lldp_collector.rs @@ -152,8 +152,8 @@ fn is_self_loopback(neighbor: &rpc_discovery::LldpSwitchData, own: &LldpId) -> b /// /// Best-effort: `None` when lldpd is unavailable or the output is malformed. fn get_local_chassis_id() -> Option { - let out = Cmd::new("bash") - .args(vec!["-c", "lldpcli -f json0 show chassis"]) + let out = Cmd::new("lldpcli") + .args(vec!["-f", "json0", "show", "chassis"]) .output() .map_err(|e| warn!("Could not read local LLDP chassis: {e}")) .ok()?; @@ -229,9 +229,8 @@ fn lldp_for_device(device: &Device) -> Vec { /// Get raw `lldpcli -f json0` output for a port. pub fn get_lldp_port_info(port: &str) -> LldpCollectorResult { - let lldp_cmd = format!("lldpcli -f json0 show neighbors ports {port}"); - Cmd::new("bash") - .args(vec!["-c", lldp_cmd.as_str()]) + Cmd::new("lldpcli") + .args(vec!["-f", "json0", "show", "neighbors", "ports", port]) .output() .map_err(|e| { warn!("Could not discover LLDP peer for {port}, {e}"); From cbfa107504b745644e12c43da2ca49fb464df3ea Mon Sep 17 00:00:00 2001 From: Anatolii Kurotych Date: Tue, 14 Jul 2026 19:09:09 +0300 Subject: [PATCH 09/10] Add LLDP-MED inventory fields (serial, manufacturer, model) to the LLDP collector and proto Signed-off-by: Anatolii Kurotych --- crates/host-support/src/lldp_collector.rs | 75 ++++++++++++++++++++++- crates/rpc/proto/machine_discovery.proto | 4 ++ 2 files changed, 78 insertions(+), 1 deletion(-) diff --git a/crates/host-support/src/lldp_collector.rs b/crates/host-support/src/lldp_collector.rs index 3da7d25c13..33b7a813b8 100644 --- a/crates/host-support/src/lldp_collector.rs +++ b/crates/host-support/src/lldp_collector.rs @@ -60,6 +60,24 @@ pub struct LldpPort { pub ttl: Vec, } +/// LLDP-MED inventory (`lldp-med[].inventory[]`), advertised by some neighbors +/// (e.g. BlueField DPUs). Every field is optional. +#[derive(Debug, Deserialize, Serialize, Clone, Default)] +pub struct LldpInventory { + #[serde(default)] + pub serial: Vec, + #[serde(default)] + pub manufacturer: Vec, + #[serde(default)] + pub model: Vec, +} + +#[derive(Debug, Deserialize, Serialize, Clone, Default)] +pub struct LldpMed { + #[serde(default)] + pub inventory: Vec, +} + #[derive(Debug, Deserialize, Serialize, Clone)] pub struct LldpInterfaceEntry { pub name: String, // local interface name (host side) @@ -67,6 +85,8 @@ pub struct LldpInterfaceEntry { pub chassis: Vec, #[serde(default)] pub port: Vec, + #[serde(rename = "lldp-med", default)] + pub lldp_med: Vec, } #[derive(Debug, Deserialize, Serialize, Clone, Default)] @@ -230,7 +250,15 @@ fn lldp_for_device(device: &Device) -> Vec { /// Get raw `lldpcli -f json0` output for a port. pub fn get_lldp_port_info(port: &str) -> LldpCollectorResult { Cmd::new("lldpcli") - .args(vec!["-f", "json0", "show", "neighbors", "ports", port]) + .args(vec![ + "-f", + "json0", + "show", + "neighbors", + "ports", + port, + "details", + ]) .output() .map_err(|e| { warn!("Could not discover LLDP peer for {port}, {e}"); @@ -274,6 +302,11 @@ fn parse_port_lldp(lldp_json: &str) -> LldpCollectorResult &Vec| { + inventory.and_then(|inv| select(inv).first().map(|v| v.value.clone())) + }; + // The `deprecated` allow keeps the legacy combined `id`/`remote_port` // strings populated for backward compatibility until consumers migrate // to the split *_type/*_value fields. @@ -297,6 +330,9 @@ fn parse_port_lldp(lldp_json: &str) -> LldpCollectorResult Date: Tue, 14 Jul 2026 19:15:21 +0300 Subject: [PATCH 10/10] Add 'DAEMON_ARGS="-M 1"' to /etc/default/lldpd (scout-os) Signed-off-by: Anatolii Kurotych --- pxe/mkosi.profiles/scout-oss-aarch64/mkosi.postinst.chroot | 1 + pxe/mkosi.profiles/scout-oss-x86_64/mkosi.postinst.chroot | 1 + 2 files changed, 2 insertions(+) diff --git a/pxe/mkosi.profiles/scout-oss-aarch64/mkosi.postinst.chroot b/pxe/mkosi.profiles/scout-oss-aarch64/mkosi.postinst.chroot index f7012fb56d..0e57825a71 100755 --- a/pxe/mkosi.profiles/scout-oss-aarch64/mkosi.postinst.chroot +++ b/pxe/mkosi.profiles/scout-oss-aarch64/mkosi.postinst.chroot @@ -30,6 +30,7 @@ apt-get ${APT_SANDBOX_OPTS} -y install ${NV_PACKAGES} # lldpd must be running for forge-scout to collect LLDP neighbors via lldpcli. # Enable it explicitly rather than relying on the package postinst. +echo 'DAEMON_ARGS="-M 1"' >/etc/default/lldpd systemctl enable lldpd # ============================================================================ diff --git a/pxe/mkosi.profiles/scout-oss-x86_64/mkosi.postinst.chroot b/pxe/mkosi.profiles/scout-oss-x86_64/mkosi.postinst.chroot index f7012fb56d..0e57825a71 100755 --- a/pxe/mkosi.profiles/scout-oss-x86_64/mkosi.postinst.chroot +++ b/pxe/mkosi.profiles/scout-oss-x86_64/mkosi.postinst.chroot @@ -30,6 +30,7 @@ apt-get ${APT_SANDBOX_OPTS} -y install ${NV_PACKAGES} # lldpd must be running for forge-scout to collect LLDP neighbors via lldpcli. # Enable it explicitly rather than relying on the package postinst. +echo 'DAEMON_ARGS="-M 1"' >/etc/default/lldpd systemctl enable lldpd # ============================================================================