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/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..73dca4f614 100644 --- a/crates/host-support/src/hardware_enumeration/dpu.rs +++ b/crates/host-support/src/hardware_enumeration/dpu.rs @@ -15,19 +15,9 @@ * 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}; - -const LLDP_PORTS: &[&str] = &["p0", "p1", "oob_net0"]; +use rpc::machine_discovery::DpuData; #[derive(thiserror::Error, Debug)] pub enum DpuEnumerationError { @@ -39,162 +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), -} - -#[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); - 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 -} - -/// 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 { @@ -306,20 +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(lldp_info) => { - switches.push(lldp_info); - } - Err(_e) => {} - } - } - } - let dpu_info = DpuData { part_number: part_number[0].clone(), part_description: device_description[0].clone(), @@ -327,137 +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 will be done in next PRs). + switches: vec![], }; Ok(dpu_info) } - -#[cfg(test)] -mod tests { - use carbide_test_support::Outcome::*; - use carbide_test_support::{scenarios, 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, - } - ); - } - - // `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..aa65c2123e 100644 --- a/crates/host-support/src/lib.rs +++ b/crates/host-support/src/lib.rs @@ -29,6 +29,8 @@ pub mod agent_config; pub mod dpa_cmds; #[cfg(feature = "linux-build")] pub mod hardware_enumeration; +#[cfg(feature = "linux-build")] +pub mod lldp_collector; 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..33b7a813b8 --- /dev/null +++ b/crates/host-support/src/lldp_collector.rs @@ -0,0 +1,514 @@ +use std::fs; + +use ::rpc::machine_discovery as rpc_discovery; +use carbide_utils::cmd::Cmd; +use libudev::Device; +use serde::{Deserialize, Serialize}; +use tracing::{debug, warn}; + +use crate::hardware_enumeration::PCI_SUBCLASS; + +#[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; + +// `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 LldpValue { + pub value: String, +} + +#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Eq)] +pub struct LldpId { + #[serde(rename = "type")] + pub id_type: String, + pub value: String, +} + +#[derive(Debug, Deserialize, Serialize, Clone, Default)] +pub struct LldpChassis { + #[serde(default)] + 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, Default)] +pub struct LldpPort { + #[serde(default)] + pub id: Vec, + #[serde(default)] + pub descr: Vec, + #[serde(default)] + 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) + #[serde(default)] + pub chassis: Vec, + #[serde(default)] + pub port: Vec, + #[serde(rename = "lldp-med", default)] + pub lldp_med: Vec, +} + +#[derive(Debug, Deserialize, Serialize, Clone, Default)] +pub struct LldpRoot { + #[serde(default)] + pub interface: Vec, +} + +#[derive(Debug, Deserialize, Serialize, Clone, Default)] +pub struct LldpResponse { + #[serde(default)] + pub lldp: Vec, +} + +/// 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, 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> +{ + let context = libudev::Context::new()?; + let mut enumerator = libudev::Enumerator::new(&context)?; + enumerator.match_subsystem("net")?; + + let local_chassis_id = get_local_chassis_id(); + + 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; + }; + out.extend( + lldp_for_device(&device) + .into_iter() + // When the local chassis id is unknown nothing is filtered — + // better a spurious self-entry than a lost fabric link. + .filter(|lldp| match &local_chassis_id { + Some(own) => !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("lldpcli") + .args(vec!["-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. +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 neighbors for a net device's kernel interface name. +/// +/// 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}"); + Vec::new() + } + } +} + +/// 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, + "details", + ]) + .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 structs for discovery info. +/// +/// 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)?) +} + +/// 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(); + + let inventory = entry.lldp_med.first().and_then(|med| med.inventory.first()); + let inv_field = |select: fn(&LldpInventory) -> &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. + #[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, + serial: inv_field(|inv| &inv.serial), + manufacturer: inv_field(|inv| &inv.manufacturer), + model: inv_field(|inv| &inv.model), + }); + } + + Ok(neighbors) +} + +#[cfg(test)] +mod tests { + use super::{LldpId, is_self_loopback, parse_local_chassis_id, parse_port_lldp, rpc_discovery}; + + // 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] + #[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()); + } + } + + #[test] + #[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")); + } + + // Neighbor advertising LLDP-MED inventory (a BlueField DPU). serial / + // manufacturer / model live under `lldp-med[].inventory[]`. + const INVENTORY_NEIGHBOR: &str = r#"{ + "lldp": [ + { "interface": [ + { "name": "enp1s0np0", "chassis": [ + { "id": [{"type":"mac","value":"00:11:22:33:44:55"}], + "name": [{"value":"example-dpu-01"}] }], + "port": [{ "id": [{"type":"mac","value":"00:11:22:33:44:66"}], "ttl": [{"value":"120"}] }], + "lldp-med": [{ "inventory": [{ + "serial": [{"value":"SN0123456789"}], + "manufacturer": [{"value":"https://example.com"}], + "model": [{"value":"BlueField-3 DPU"}] }] }] } + ] } + ] + }"#; + + #[test] + fn parses_lldp_med_inventory() { + let neighbors = parse_port_lldp(INVENTORY_NEIGHBOR).expect("parse"); + assert_eq!(neighbors.len(), 1); + let n = &neighbors[0]; + assert_eq!(n.serial.as_deref(), Some("SN0123456789")); + assert_eq!(n.manufacturer.as_deref(), Some("https://example.com")); + assert_eq!(n.model.as_deref(), Some("BlueField-3 DPU")); + } + + #[test] + fn parses_missing_inventory_as_none() { + let neighbors = parse_port_lldp(SINGLE_NEIGHBOR).expect("parse"); + assert_eq!(neighbors.len(), 1); + let n = &neighbors[0]; + assert!(n.serial.is_none()); + assert!(n.manufacturer.is_none()); + assert!(n.model.is_none()); + } + + #[test] + fn parses_no_neighbors_as_empty() { + let neighbors = parse_port_lldp(r#"{"lldp":[{"interface":[]}]}"#).expect("parse"); + assert!(neighbors.is_empty()); + } + + // Shape of `lldpcli -f json0 show chassis` on a DPU. + const LOCAL_CHASSIS: &str = r#"{ + "local-chassis": [ + { "chassis": [ + { "id": [{"type":"mac","value":"58:a2:e1:54:6f:ae"}], + "name": [{"value":"10-213-2-193.forge.local"}], + "descr": [{"value":"Forge-SRE"}] }] } + ] + }"#; + + #[test] + fn parses_local_chassis_id() { + let id = parse_local_chassis_id(LOCAL_CHASSIS).expect("chassis id"); + assert_eq!(id.id_type, "mac"); + assert_eq!(id.value, "58:a2:e1:54:6f:ae"); + } + + #[test] + fn parses_local_chassis_id_absent() { + assert!(parse_local_chassis_id(r#"{"local-chassis":[{"chassis":[{}]}]}"#).is_none()); + assert!(parse_local_chassis_id("not json").is_none()); + } + + // Representor pairs on a DPU report the DPU's own chassis as the neighbor; + // only a matching (type, value) pair marks the loopback — a genuinely + // different switch must be kept. + #[test] + fn self_loopback_detected_by_chassis_id() { + let own = LldpId { + id_type: "mac".into(), + value: "58:a2:e1:54:6f:ae".into(), + }; + let neighbor = |id_type: &str, id_value: &str| rpc_discovery::LldpSwitchData { + id_type: id_type.into(), + id_value: id_value.into(), + ..Default::default() + }; + + 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 + )); + // same string value but different id type -> not self + assert!(!is_self_loopback( + &neighbor("local", "58:a2:e1:54:6f:ae"), + &own + )); + } +} 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..c133ed22a9 100644 --- a/crates/rpc/proto/machine_discovery.proto +++ b/crates/rpc/proto/machine_discovery.proto @@ -136,11 +136,24 @@ 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; + // LLDP-MED inventory fields, when the neighbor advertises them. + optional string serial = 11; + optional string manufacturer = 12; + optional string model = 13; } 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() }) } } 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?; 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..0e57825a71 100755 --- a/pxe/mkosi.profiles/scout-oss-aarch64/mkosi.postinst.chroot +++ b/pxe/mkosi.profiles/scout-oss-aarch64/mkosi.postinst.chroot @@ -28,6 +28,11 @@ 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. +echo 'DAEMON_ARGS="-M 1"' >/etc/default/lldpd +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..0e57825a71 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,11 @@ 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. +echo 'DAEMON_ARGS="-M 1"' >/etc/default/lldpd +systemctl enable lldpd + # ============================================================================ # FORGE-SCOUT # ============================================================================