diff --git a/Cargo.lock b/Cargo.lock index 3a0c6a6928..8305bbfa0f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -840,6 +840,7 @@ dependencies = [ "carbide-api-model", "carbide-network", "carbide-test-support", + "hw-platform", "itertools 0.14.0", "lazy_static", "mac_address", @@ -2092,6 +2093,7 @@ dependencies = [ "http-body-util", "humantime", "humantime-serde", + "hw-platform", "hyper", "hyper-rustls", "hyper-util", @@ -6025,6 +6027,14 @@ dependencies = [ "serde", ] +[[package]] +name = "hw-platform" +version = "0.1.0" +dependencies = [ + "bmc-vendor", + "carbide-test-support", +] + [[package]] name = "hybrid-array" version = "0.4.13" diff --git a/crates/bmc-explorer/Cargo.toml b/crates/bmc-explorer/Cargo.toml index 36b2ce8ade..fd1999b006 100644 --- a/crates/bmc-explorer/Cargo.toml +++ b/crates/bmc-explorer/Cargo.toml @@ -31,6 +31,7 @@ path = "tests/integration/main.rs" # [local-dependencies] # DO NOT PUT DEPENDENCIES OTHER THAN LOCAL DEPS HERE, THEY SHOULD ALL HAVE 'path =' IN THEM. bmc-vendor = { path = "../bmc-vendor" } +hw-platform = { path = "../hw-platform" } bmc-mock = { path = "../bmc-mock", optional = true } carbide-network = { path = "../network", default-features = false } carbide-api-model = { path = "../api-model", default-features = false } diff --git a/crates/bmc-explorer/src/chassis.rs b/crates/bmc-explorer/src/chassis.rs index a0a761ba23..c9b8d0019e 100644 --- a/crates/bmc-explorer/src/chassis.rs +++ b/crates/bmc-explorer/src/chassis.rs @@ -109,16 +109,19 @@ impl ExploredChassisCollection { } } - pub(crate) fn is_liteon_powershelf(&self) -> bool { - self.members.iter().any(|m| { - m.chassis.id().into_inner() == "powershelf" - || (m.chassis.id().into_inner() == "chassis" - && m.chassis - .hardware_id() - .manufacturer - .as_ref() - .is_some_and(|mfg| mfg.as_ref().to_lowercase().contains("lite-on"))) - }) + /// Projects the members onto the identity fields `hw_platform` classifies on. + pub(crate) fn identities(&self) -> Vec> { + self.members + .iter() + .map(|m| { + let hardware_id = m.chassis.hardware_id(); + hw_platform::ChassisIdentity { + id: m.chassis.id().into_inner(), + manufacturer: hardware_id.manufacturer.map(|v| v.into_inner()), + model: hardware_id.model.map(|v| v.into_inner()), + } + }) + .collect() } pub(crate) fn liteon_power_state(&self) -> Option> { @@ -129,23 +132,13 @@ impl ExploredChassisCollection { }) } - /// Detects a Delta power shelf. Delta BMCs expose neither a `Vendor` in the - /// service root nor a `/redfish/v1/Systems` collection, so classification - /// relies on a Delta manufacturer on the power-shelf chassis (id "chassis" - /// or "powershelf"). The manufacturer gate is what distinguishes Delta from - /// the Lite-On power shelf, which shares the generic "powershelf" chassis - /// id. + /// Detects a Delta power shelf; see [`hw_platform::is_delta_powershelf`]. + /// + /// Delta detection is needed before classification runs -- it selects the + /// exploration path for a BMC that exposes no `Systems` collection -- so it + /// is reachable on its own as well as through `hw_platform::classify`. pub(crate) fn is_delta_powershelf(&self) -> bool { - self.members.iter().any(|m| { - is_delta_powershelf_chassis( - m.chassis.id().into_inner(), - m.chassis - .hardware_id() - .manufacturer - .as_ref() - .map(|mfg| **mfg), - ) - }) + hw_platform::is_delta_powershelf(&self.identities()) } /// Aggregate power state across all Delta PSUs found on the chassis members. @@ -210,13 +203,6 @@ impl ExploredChassisCollection { } } - pub(crate) fn is_gb300(&self) -> bool { - self.members.iter().any(|m| { - m.chassis.hardware_id().manufacturer == Some(Manufacturer::new("NVIDIA")) - && m.chassis.hardware_id().model == Some(Model::new("NVIDIA GB300")) - }) - } - pub(crate) fn is_mgx_c2(&self) -> bool { self.members.iter().any(|m| { let hardware_id = m.chassis.hardware_id(); @@ -228,12 +214,6 @@ impl ExploredChassisCollection { }) } - pub(crate) fn is_lenovo(&self) -> bool { - self.members - .iter() - .any(|m| m.chassis.hardware_id().manufacturer == Some(Manufacturer::new("Lenovo"))) - } - pub(crate) fn is_bluefield2(&self) -> bool { self.members .iter() @@ -503,16 +483,6 @@ fn delta_psu_power_on(ps: &NvPowerSupply) -> Option { } } -/// Delta power-shelf identity gate: a power-shelf chassis (id `chassis` or -/// `powershelf`) whose manufacturer identifies as Delta. This is what -/// distinguishes a Delta shelf from the Lite-On shelf, which shares the generic -/// `powershelf` chassis id but reports a different manufacturer. Split out so -/// the gate can be exercised in unit tests without a live BMC. -fn is_delta_powershelf_chassis(chassis_id: &str, manufacturer: Option<&str>) -> bool { - (chassis_id == "chassis" || chassis_id == "powershelf") - && manufacturer.is_some_and(|mfg| mfg.to_lowercase().contains("delta")) -} - fn is_mgx_c2_processor_module( manufacturer: Option<&str>, model: Option<&str>, @@ -577,10 +547,7 @@ impl LiteOnSuppliesState<'_> { #[cfg(test)] mod tests { - use super::{ - ModelPowerState, is_delta_powershelf_chassis, is_mgx_c2_processor_module, - powershelf_power_state, - }; + use super::{ModelPowerState, is_mgx_c2_processor_module, powershelf_power_state}; #[test] fn identifies_mgx_c2_processor_modules() { @@ -618,36 +585,6 @@ mod tests { } } - // is_delta_powershelf_chassis gates Delta detection: a power-shelf chassis - // id ("chassis"/"powershelf") AND a Delta manufacturer. The manufacturer - // check is case-insensitive and substring-based, and is what separates a - // Delta shelf from a Lite-On shelf sharing the "powershelf" chassis id. - #[test] - fn is_delta_powershelf_chassis_gates_on_id_and_manufacturer() { - let cases: [(&str, Option<&str>, bool); 9] = [ - // Delta manufacturer on either accepted power-shelf chassis id. - ("chassis", Some("DELTA"), true), - ("powershelf", Some("Delta"), true), - // Case-insensitive, substring match on the manufacturer. - ("chassis", Some("delta electronics"), true), - ("powershelf", Some("Delta Energy Systems"), true), - // Right manufacturer but a non-power-shelf chassis id is ignored. - ("Card1", Some("DELTA"), false), - ("Baseboard", Some("delta"), false), - // Power-shelf chassis id but a different (or missing) manufacturer. - ("powershelf", Some("Lite-On"), false), - ("chassis", Some("NVIDIA"), false), - ("chassis", None, false), - ]; - for (id, mfg, expected) in cases { - assert_eq!( - is_delta_powershelf_chassis(id, mfg), - expected, - "id={id:?} manufacturer={mfg:?}" - ); - } - } - // powershelf_power_state collapses per-PSU flags: all-on => On, all-off => // Off, and empty / mixed / unknown => Unknown. #[test] diff --git a/crates/bmc-explorer/src/hw/mod.rs b/crates/bmc-explorer/src/hw/mod.rs index 08f75db3ff..6b28faccf7 100644 --- a/crates/bmc-explorer/src/hw/mod.rs +++ b/crates/bmc-explorer/src/hw/mod.rs @@ -15,9 +15,14 @@ * limitations under the License. */ -use std::fmt; +//! Per-platform BMC exploration helpers. +//! +//! The [`HwType`] taxonomy and the rules that resolve one from Redfish live in +//! the `hw-platform` crate, so `carbide-health` can classify the same way +//! without depending on this crate's exploration types. They are re-exported +//! here because every caller in this crate reaches them through `hw::`. -use itertools::Itertools; +pub use hw_platform::{BiosAttr, BiosAttrValue, HwType}; pub mod bluefield; pub mod dell; @@ -30,165 +35,3 @@ pub mod supermicro; pub mod supermicro_gb300; pub mod vera_rubin; pub mod viking; - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum HwType { - Ami, - Bluefield, - Dell, - Gb200, - DgxGb300, - Hpe, - Lenovo, - LenovoAmi, - LenovoGb300, - SupermicroGb300, - Supermicro, - Viking, - LiteonPowerShelf, - DeltaPowerShelf, - NvSwitch, - VeraRubin, -} - -impl HwType { - pub const fn bmc_vendor(&self) -> Option { - match self { - Self::Ami => None, - Self::Bluefield => Some(bmc_vendor::BMCVendor::Nvidia), - Self::Dell => Some(bmc_vendor::BMCVendor::Dell), - Self::Gb200 => Some(bmc_vendor::BMCVendor::Nvidia), - // DGX GB300 uses the NVIDIA "GB BMC" (same BMC family as GB200). - Self::DgxGb300 => Some(bmc_vendor::BMCVendor::Nvidia), - Self::Hpe => Some(bmc_vendor::BMCVendor::Hpe), - Self::Lenovo => Some(bmc_vendor::BMCVendor::Lenovo), - Self::LenovoAmi => Some(bmc_vendor::BMCVendor::LenovoAMI), - Self::LenovoGb300 => Some(bmc_vendor::BMCVendor::LenovoAMI), - // SMC GB300 runs a Supermicro (OpenBMC) host BMC. - Self::SupermicroGb300 => Some(bmc_vendor::BMCVendor::Supermicro), - Self::LiteonPowerShelf => Some(bmc_vendor::BMCVendor::Liteon), - Self::DeltaPowerShelf => Some(bmc_vendor::BMCVendor::Delta), - Self::NvSwitch => Some(bmc_vendor::BMCVendor::Nvidia), - Self::Supermicro => Some(bmc_vendor::BMCVendor::Supermicro), - Self::Viking => Some(bmc_vendor::BMCVendor::Nvidia), - Self::VeraRubin => Some(bmc_vendor::BMCVendor::Nvidia), - } - } - - pub const fn infinite_boot_enabled_attr(&self) -> Option> { - match self { - Self::Ami => Some(BiosAttr::new_str("EndlessBoot", "Enabled")), - Self::Bluefield => None, - Self::Dell => Some(BiosAttr::new_str("BootSeqRetry", "Enabled")), - Self::Gb200 => Some(BiosAttr::new_str("EmbeddedUefiShell", "Disabled")), - // The DGX GB300 BIOS exposes EmbeddedUefiShell, but the value that means - // infinite-boot-enabled is not yet characterized on hardware (GB200's polarity - // is not assumed to carry over). Left None until confirmed on a tray. - // TODO(dgx-gb300): set the infinite-boot attribute from the DGX GB300 BIOS. - Self::DgxGb300 => None, - Self::Hpe => None, - Self::Lenovo => Some(BiosAttr::new_str("BootModes_InfiniteBootRetry", "Enabled")), - Self::LenovoAmi => Some(BiosAttr::new_str("EndlessBoot", "Enabled")), - Self::LenovoGb300 => Some(BiosAttr::new_int("LEM0003", 50)), - // TODO(smc): confirm the SMC GB300 infinite-boot BIOS attribute from the tray BIOS. - Self::SupermicroGb300 => None, - Self::LiteonPowerShelf => None, - Self::DeltaPowerShelf => None, - Self::NvSwitch => None, - Self::Supermicro => None, - Self::Viking => Some(BiosAttr::new_str("NvidiaInfiniteboot", "Enable")), - // Same EmbeddedUefiShell polarity as GB200 / libredfish NvidiaGBx00. - Self::VeraRubin => Some(BiosAttr::new_str("EmbeddedUefiShell", "Disabled")), - } - } -} - -#[derive(Clone, Copy)] -pub struct BiosAttr<'a> { - pub key: &'a str, - pub value: BiosAttrValue<'a>, -} - -impl BiosAttr<'_> { - pub const fn new_bool(key: &'static str, value: bool) -> BiosAttr<'static> { - BiosAttr { - key, - value: BiosAttrValue::Bool(value), - } - } - pub const fn new_str(key: &'static str, value: &'static str) -> BiosAttr<'static> { - BiosAttr { - key, - value: BiosAttrValue::Str(value), - } - } - pub const fn new_any_str( - key: &'static str, - value: &'static [&'static str], - ) -> BiosAttr<'static> { - BiosAttr { - key, - value: BiosAttrValue::AnyStr(value), - } - } - pub const fn new_int(key: &'static str, value: i64) -> BiosAttr<'static> { - BiosAttr { - key, - value: BiosAttrValue::Int(value), - } - } -} - -#[derive(Clone, Copy)] -pub enum BiosAttrValue<'a> { - Str(&'a str), - AnyStr(&'a [&'a str]), - Bool(bool), - Int(i64), -} - -impl fmt::Display for BiosAttrValue<'_> { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - BiosAttrValue::Str(v) => v.fmt(f), - BiosAttrValue::Bool(v) => v.fmt(f), - BiosAttrValue::Int(v) => v.fmt(f), - BiosAttrValue::AnyStr(v) => write!(f, "any({})", v.iter().join(",")), - } - } -} - -#[cfg(test)] -mod tests { - use bmc_vendor::BMCVendor; - use carbide_test_support::value_scenarios; - - use super::*; - - #[test] - fn hw_type_bmc_vendor_maps_each_variant() { - value_scenarios!(run = |hardware_type: HwType| hardware_type.bmc_vendor(); - "generic AMI has no canonical vendor" { - HwType::Ami => None, - } - - "hardware types map to canonical vendors" { - HwType::Bluefield => Some(BMCVendor::Nvidia), - HwType::Dell => Some(BMCVendor::Dell), - HwType::Gb200 => Some(BMCVendor::Nvidia), - HwType::DgxGb300 => Some(BMCVendor::Nvidia), - HwType::Hpe => Some(BMCVendor::Hpe), - HwType::Lenovo => Some(BMCVendor::Lenovo), - HwType::LenovoAmi => Some(BMCVendor::LenovoAMI), - HwType::LenovoGb300 => Some(BMCVendor::LenovoAMI), - HwType::SupermicroGb300 => Some(BMCVendor::Supermicro), - HwType::Supermicro => Some(BMCVendor::Supermicro), - HwType::Viking => Some(BMCVendor::Nvidia), - HwType::LiteonPowerShelf => Some(BMCVendor::Liteon), - HwType::DeltaPowerShelf => Some(BMCVendor::Delta), - HwType::NvSwitch => Some(BMCVendor::Nvidia), - HwType::VeraRubin => Some(BMCVendor::Nvidia), - } - ); - } -} diff --git a/crates/bmc-explorer/src/lib.rs b/crates/bmc-explorer/src/lib.rs index 3401a27e8f..fa0f844da0 100644 --- a/crates/bmc-explorer/src/lib.rs +++ b/crates/bmc-explorer/src/lib.rs @@ -414,71 +414,24 @@ async fn build_delta_powershelf_report( }) } +/// Projects this crate's exploration types onto the plain identity fields the +/// shared classifier reads. The rules themselves live in `hw-platform` so +/// `carbide-health` resolves the same platform from a `ServiceRoot`, a +/// `ComputerSystem`, and a `Chassis` collection without depending on this crate. pub(crate) fn hw_type( root: &nv_redfish::ServiceRoot, explored_system: &ExploredComputerSystem, explored_chassis: &ExploredChassisCollection, ) -> Option { - let system = &explored_system.system; - let oem_id = root.oem_id().map(|v| v.into_inner()); - - // GB300 is an NVIDIA HGX platform identity, recognized by the NVIDIA "NVIDIA GB300" - // GPU chassis (`is_gb300()`) independent of the host BMC vendor. Resolve it before the - // host-vendor match below so platform classification is not gated on the host ODM; the - // ODM only selects the ODM-specific variant. - if explored_chassis.is_gb300() { - // Lenovo GB300: AMI host BMC + Lenovo host chassis. - if explored_chassis.is_lenovo() { - return Some(hw::HwType::LenovoGb300); - } - // DGX GB300: NVIDIA "GB BMC" host (same BMC family as GB200). Resolved here, ahead of - // the GB200 arm below, since it shares GB200's ServiceRoot signature -- the GB300 GPU - // chassis (`is_gb300()`) is what distinguishes it from a real GB200. - if root.vendor() == Some(Vendor::new("NVIDIA")) - && root.product() == Some(Product::new("GB BMC")) - { - return Some(hw::HwType::DgxGb300); - } - // SMC GB300: Supermicro OpenBMC host. - if root.vendor() == Some(Vendor::new("Supermicro")) { - return Some(hw::HwType::SupermicroGb300); - } - } - - root.vendor() - .map(|v| v.into_inner()) - .or_else(|| (oem_id == Some("Supermicro")).then_some("Supermicro")) - .and_then(|vendor_id| match vendor_id { - "AMI" if system.id().into_inner() == "DGX" => Some(hw::HwType::Viking), - "AMI" => Some(hw::HwType::Ami), - "Dell" => Some(hw::HwType::Dell), - "Lenovo" if oem_id == Some("Ami") => Some(hw::HwType::LenovoAmi), - "Lenovo" if oem_id != Some("Ami") => Some(hw::HwType::Lenovo), - "Supermicro" => Some(hw::HwType::Supermicro), - "HPE" => Some(hw::HwType::Hpe), - "Nvidia" if is_bluefield_system_id(system.id()) => Some(hw::HwType::Bluefield), - "NVIDIA" if root.product() == Some(Product::new("VR NVL72")) => { - Some(hw::HwType::VeraRubin) - } - "WIWYNN" | "NVIDIA" - if root.product() == Some(Product::new("GB200 NVL")) - || root.product() == Some(Product::new("GB BMC")) => - { - Some(hw::HwType::Gb200) - } - "NVIDIA" if root.product() == Some(Product::new("P3809")) => Some(hw::HwType::NvSwitch), - _ => None, - }) - .or_else(|| { - explored_chassis - .is_liteon_powershelf() - .then_some(hw::HwType::LiteonPowerShelf) - }) - .or_else(|| { - explored_chassis - .is_delta_powershelf() - .then_some(hw::HwType::DeltaPowerShelf) - }) + hw_platform::classify( + hw_platform::ServiceIdentity { + vendor: root.vendor().map(|v| v.into_inner()), + product: root.product().map(|v| v.into_inner()), + oem_id: root.oem_id().map(|v| v.into_inner()), + system_id: Some(explored_system.system.id().into_inner()), + }, + &explored_chassis.identities(), + ) } fn lockdown_status( diff --git a/crates/health/Cargo.toml b/crates/health/Cargo.toml index cfab97d80d..c3794ca2bc 100644 --- a/crates/health/Cargo.toml +++ b/crates/health/Cargo.toml @@ -29,6 +29,7 @@ path = "src/main.rs" [dependencies] carbide-instrument = { path = "../instrument" } +hw-platform = { path = "../hw-platform" } arc-swap = { workspace = true } async-trait = { workspace = true } base64 = { workspace = true } diff --git a/crates/health/benches/collector_pipeline.rs b/crates/health/benches/collector_pipeline.rs index 8f9f9897b4..848505510b 100644 --- a/crates/health/benches/collector_pipeline.rs +++ b/crates/health/benches/collector_pipeline.rs @@ -52,6 +52,7 @@ impl DataSink for CountingSink { fn event_context() -> EventContext { EventContext { + platform: Default::default(), endpoint_key: "42:9e:b1:bd:9d:dd".to_string(), addr: BmcAddr { ip: IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1)), diff --git a/crates/health/benches/processor_pipeline.rs b/crates/health/benches/processor_pipeline.rs index ff2599ddfc..281f0135e1 100644 --- a/crates/health/benches/processor_pipeline.rs +++ b/crates/health/benches/processor_pipeline.rs @@ -88,6 +88,7 @@ impl EventProcessor for ReemitProcessor { fn event_context() -> EventContext { EventContext { + platform: Default::default(), endpoint_key: "42:9e:b1:bd:9d:dd".to_string(), addr: BmcAddr { ip: IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1)), @@ -268,6 +269,7 @@ fn rack_event_contexts(rack_id: &str, tray_count: usize) -> Vec { .map(|idx| { let mac = format!("42:9e:b1:bd:{:02x}:{:02x}", idx / 256, idx % 256); EventContext { + platform: Default::default(), endpoint_key: mac.clone(), addr: BmcAddr { ip: IpAddr::V4(Ipv4Addr::new(10, 0, 0, (idx + 1) as u8)), diff --git a/crates/health/benches/sink_pipeline.rs b/crates/health/benches/sink_pipeline.rs index a54091db18..4043f2f0ec 100644 --- a/crates/health/benches/sink_pipeline.rs +++ b/crates/health/benches/sink_pipeline.rs @@ -62,6 +62,7 @@ fn event_context() -> EventContext { fn event_context_for_machine(machine_id: &str) -> EventContext { EventContext { + platform: Default::default(), endpoint_key: "42:9e:b1:bd:9d:dd".to_string(), addr: BmcAddr { ip: IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1)), diff --git a/crates/health/src/api_client.rs b/crates/health/src/api_client.rs index 59c9cb8a0e..a9816bc29a 100644 --- a/crates/health/src/api_client.rs +++ b/crates/health/src/api_client.rs @@ -44,7 +44,7 @@ use crate::bmc::{ }; use crate::endpoint::{ BmcAddr, BmcCredentials, BmcEndpoint, EndpointMetadata, EndpointSource, MachineData, - PowerShelfData, SharedSystemUuid, SwitchData, SwitchEndpointRole, + PowerShelfData, SharedPlatform, SharedSystemUuid, SwitchData, SwitchEndpointRole, }; use crate::metrics::BmcLatencyMetrics; @@ -306,6 +306,7 @@ struct CachedBmcClient { client: Arc, kind: ApiCredentialKind, system_uuid: SharedSystemUuid, + platform: SharedPlatform, } impl ApiEndpointSource { @@ -657,6 +658,7 @@ impl ApiEndpointSource { rack_id, labels: Default::default(), bmc: cached.client, + platform: cached.platform, })) } } @@ -685,6 +687,7 @@ fn cache_or_create_bmc_client( client, kind: credential_kind, system_uuid: SharedSystemUuid::default(), + platform: SharedPlatform::default(), }; cache.insert(mac, cached.clone()); Ok(cached) diff --git a/crates/health/src/collectors/leak_detector.rs b/crates/health/src/collectors/leak_detector.rs index 09f3975a28..7bf6c3e6f3 100644 --- a/crates/health/src/collectors/leak_detector.rs +++ b/crates/health/src/collectors/leak_detector.rs @@ -342,6 +342,7 @@ mod tests { fn context() -> EventContext { EventContext { + platform: Default::default(), endpoint_key: "42:9e:b1:bd:9d:dd".to_string(), addr: BmcAddr { ip: IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1)), diff --git a/crates/health/src/collectors/nvue/gnmi/on_change_processor.rs b/crates/health/src/collectors/nvue/gnmi/on_change_processor.rs index 2c8a99590e..104520b7f8 100644 --- a/crates/health/src/collectors/nvue/gnmi/on_change_processor.rs +++ b/crates/health/src/collectors/nvue/gnmi/on_change_processor.rs @@ -374,6 +374,7 @@ mod tests { fn test_event_context(collector_type: &'static str) -> EventContext { EventContext { + platform: Default::default(), endpoint_key: "aa:bb:cc:dd:ee:ff".to_string(), addr: BmcAddr { ip: "10.0.0.1".parse().unwrap(), @@ -773,6 +774,7 @@ mod tests { stream_metrics, Some(sink.clone()), EventContext { + platform: Default::default(), endpoint_key: "aa:bb:cc:dd:ee:ff".to_string(), addr: BmcAddr { ip: "10.0.0.1".parse().unwrap(), diff --git a/crates/health/src/collectors/nvue/gnmi/sample_processor.rs b/crates/health/src/collectors/nvue/gnmi/sample_processor.rs index 7304cb0246..84de186f96 100644 --- a/crates/health/src/collectors/nvue/gnmi/sample_processor.rs +++ b/crates/health/src/collectors/nvue/gnmi/sample_processor.rs @@ -1155,6 +1155,7 @@ mod tests { mac: MacAddress::from_str("AA:BB:CC:DD:EE:FF").unwrap(), }; let event_context = EventContext { + platform: Default::default(), endpoint_key: "aa:bb:cc:dd:ee:ff".to_string(), addr, collector_type: NVUE_GNMI_SAMPLE_STREAM_ID, @@ -1219,6 +1220,7 @@ mod tests { let proc = GnmiSampleProcessor { data_sink: Some(sink.clone()), event_context: EventContext { + platform: Default::default(), endpoint_key: "aa:bb:cc:dd:ee:ff".to_string(), addr: BmcAddr { ip: "10.0.0.1".parse().unwrap(), diff --git a/crates/health/src/collectors/nvue/rest/collector.rs b/crates/health/src/collectors/nvue/rest/collector.rs index e71882b3af..8aa2d5c30f 100644 --- a/crates/health/src/collectors/nvue/rest/collector.rs +++ b/crates/health/src/collectors/nvue/rest/collector.rs @@ -1367,6 +1367,7 @@ mod tests { .expect("rest client builds"); let event_context = EventContext { + platform: Default::default(), endpoint_key: "test-switch".to_string(), addr: addr.clone(), collector_type: COLLECTOR_NAME, diff --git a/crates/health/src/config.rs b/crates/health/src/config.rs index e8c61b77ff..36d927f65e 100644 --- a/crates/health/src/config.rs +++ b/crates/health/src/config.rs @@ -331,10 +331,13 @@ impl StaticBmcEndpoint { } const RESERVED_LABELS: &[&str] = &[ + "bmc_product", + "bmc_vendor", "collector_type", "endpoint_ip", "endpoint_key", "endpoint_mac", + "hw_platform", "machine_id", "machine_slot_number", "machine_tray_index", @@ -4535,7 +4538,16 @@ machine = { id = "fm100htjtiaehv1n5vh67tbmqq4eabcjdng40f7jupsadbedhruh6rag1l0", #[test] fn test_static_endpoint_rejects_invalid_or_reserved_label_names() { - for (name, expected) in [("bad-label", "must match"), ("system_uuid", "is reserved")] { + // Reserved names are the ones health derives itself. A custom label + // sharing one would silently overwrite discovered identity in the + // exported attributes, so it is refused at config load instead. + for (name, expected) in [ + ("bad-label", "must match"), + ("system_uuid", "is reserved"), + ("hw_platform", "is reserved"), + ("bmc_vendor", "is reserved"), + ("bmc_product", "is reserved"), + ] { let toml_content = format!( r#" [endpoint_sources.carbide_api] diff --git a/crates/health/src/discovery/identity.rs b/crates/health/src/discovery/identity.rs index 64c7235860..48d2f213c1 100644 --- a/crates/health/src/discovery/identity.rs +++ b/crates/health/src/discovery/identity.rs @@ -15,10 +15,12 @@ * limitations under the License. */ -use nv_redfish::ServiceRoot; +use hw_platform::{ChassisIdentity, ServiceIdentity}; +use nv_redfish::core::Bmc; +use nv_redfish::{Resource, ServiceRoot}; use crate::HealthError; -use crate::endpoint::{BmcEndpoint, EndpointMetadata}; +use crate::endpoint::{BmcAddr, BmcEndpoint, BmcPlatform, EndpointMetadata}; struct SystemIdentity { id: String, @@ -38,58 +40,203 @@ fn select_primary_system(systems: &[SystemIdentity]) -> Option<&SystemIdentity> .or_else(|| systems.first()) } -/// Resolves the primary ComputerSystem UUID when it is not already known. +/// Resolves the endpoint identity discovery owns: the primary ComputerSystem +/// UUID, and the hardware platform. +/// +/// Both are answered from one `ServiceRoot` fetch. Each is stored in write-once +/// shared state, so a result reached after collectors started still propagates +/// to them, and an endpoint whose identity is already known makes no request at +/// all. +/// +/// Failure is the caller's to log and swallow: identity is enrichment, and an +/// endpoint whose BMC would not answer must still be collected from. An +/// unresolved cell is retried on the next discovery pass. +pub(super) async fn ensure_endpoint_identity(endpoint: &BmcEndpoint) -> Result<(), HealthError> { + // The switch host side speaks NVUE/gNMI and exposes no Redfish service; + // probing it spends a connection attempt per pass to learn nothing. + if !endpoint.supports_redfish() { + return Ok(()); + } + + let machine = match endpoint.metadata.as_ref() { + Some(EndpointMetadata::Machine(machine)) => Some(machine), + _ => None, + }; + + let system_uuid_pending = machine.is_some_and(|machine| !machine.system_uuid.initialized()); + if !system_uuid_pending && endpoint.platform.initialized() { + return Ok(()); + } + + let root = ServiceRoot::new(endpoint.bmc().clone()).await?; + let primary_system = primary_system_identity(&root, &endpoint.addr).await; + + // An unreadable ComputerSystem collection is permanent on some endpoints: + // both power shelves answer `/redfish/v1/Systems` with 404, and holding + // their platform hostage to it would leave them unidentified forever. + // Classification proceeds without a system id where the id provably cannot + // change the answer, and is deferred to the next pass where it could -- + // guessing there would cache a confidently wrong platform. + let vendor = root.vendor().map(|value| value.into_inner()); + let system_id_available = primary_system.is_ok() || !hw_platform::needs_system_id(vendor); + + if system_id_available { + endpoint + .platform + .get_or_try_init(|| async { + let primary = primary_system.as_ref().ok().and_then(Option::as_ref); + resolve_platform(&root, primary, &endpoint.addr).await + }) + .await?; + } + + // The UUID gets no such fallback. An unreadable collection is not evidence + // that a machine has no UUID, so the error propagates and the cell is left + // uninitialized for the next pass to retry. + let primary_system = primary_system?; + + if let Some(machine) = machine { + machine + .system_uuid + .get_or_try_init(|| async { + let Some(primary) = primary_system.as_ref() else { + return Ok(None); + }; + if primary.uuid.is_none() { + tracing::warn!( + bmc_address = ?endpoint.addr, + system_id = %primary.id, + "Primary ComputerSystem does not expose a UUID" + ); + } + Ok::, HealthError>(primary.uuid) + }) + .await?; + } + + Ok(()) +} + +/// The ComputerSystem that identifies this endpoint, or `None` when the BMC +/// exposes no usable system collection. /// /// A system with a non-empty BIOS version is preferred because BMCs may expose /// auxiliary systems alongside the host. When no system has BIOS metadata, the /// first collection member is used. -pub(super) async fn ensure_primary_system_uuid(endpoint: &BmcEndpoint) -> Result<(), HealthError> { - let Some(EndpointMetadata::Machine(machine)) = endpoint.metadata.as_ref() else { - return Ok(()); +async fn primary_system_identity( + root: &ServiceRoot, + addr: &BmcAddr, +) -> Result, HealthError> { + let Some(systems) = root.systems().await? else { + // Both power shelves are like this, so it is not on its own a fault. + tracing::debug!( + bmc_address = ?addr, + "BMC does not expose a ComputerSystem collection" + ); + return Ok(None); }; - machine - .system_uuid - .get_or_try_init(|| async { - let root = ServiceRoot::new(endpoint.bmc().clone()).await?; - let Some(systems) = root.systems().await? else { - tracing::warn!( - bmc_address = ?endpoint.addr, - "BMC does not expose a ComputerSystem collection" - ); - return Ok(None); - }; - let systems = systems.members().await?; - let identities: Vec = systems - .iter() - .map(|system| { - let raw = system.raw(); - SystemIdentity { - id: raw.base.id.clone(), - uuid: raw.uuid.flatten(), - bios_version: raw.bios_version.clone().flatten(), - } - }) - .collect(); - let Some(primary) = select_primary_system(&identities) else { - tracing::warn!( - bmc_address = ?endpoint.addr, - "BMC exposes an empty ComputerSystem collection" - ); - return Ok(None); - }; - if primary.uuid.is_none() { - tracing::warn!( - bmc_address = ?endpoint.addr, - system_id = %primary.id, - "Primary ComputerSystem does not expose a UUID" - ); + let systems = systems.members().await?; + let identities: Vec = systems + .iter() + .map(|system| { + let raw = system.raw(); + SystemIdentity { + id: raw.base.id.clone(), + uuid: raw.uuid.flatten(), + bios_version: raw.bios_version.clone().flatten(), } - Ok::, HealthError>(primary.uuid) }) - .await?; + .collect(); - Ok(()) + if identities.is_empty() { + tracing::warn!( + bmc_address = ?addr, + "BMC exposes an empty ComputerSystem collection" + ); + } + + Ok( + select_primary_system(&identities).map(|primary| SystemIdentity { + id: primary.id.clone(), + uuid: primary.uuid, + bios_version: primary.bios_version.clone(), + }), + ) +} + +/// Classifies the hardware platform, keeping the raw vendor and product strings +/// whether or not classification succeeded. +/// +/// The chassis collection is fetched because it is load-bearing, not +/// supplementary: a DGX GB300 tray and a GB200 tray have byte-identical service +/// roots, and only the NVIDIA GB300 GPU chassis tells them apart. A fetch +/// *error* is propagated so the endpoint is retried rather than pinned to a +/// classification made without that evidence; a BMC that exposes no chassis +/// collection at all is a fact, and classification proceeds without it. +async fn resolve_platform( + root: &ServiceRoot, + primary_system: Option<&SystemIdentity>, + addr: &BmcAddr, +) -> Result { + let chassis = match root.chassis().await? { + Some(collection) => collection.members().await?, + None => { + tracing::debug!( + bmc_address = ?addr, + "BMC does not expose a Chassis collection" + ); + Vec::new() + } + }; + let chassis: Vec> = chassis + .iter() + .map(|chassis| { + let hardware_id = chassis.hardware_id(); + ChassisIdentity { + id: chassis.id().into_inner(), + manufacturer: hardware_id.manufacturer.map(|value| value.into_inner()), + model: hardware_id.model.map(|value| value.into_inner()), + } + }) + .collect(); + + let vendor = root.vendor().map(|value| value.into_inner()); + let product = root.product().map(|value| value.into_inner()); + let hw_type = hw_platform::classify( + ServiceIdentity { + vendor, + product, + oem_id: root.oem_id().map(|value| value.into_inner()), + // The BIOS-bearing system, not merely the first: on a BMC that + // exposes an auxiliary baseboard beside the host, the host is the + // one whose id names the platform. + system_id: primary_system.map(|system| system.id.as_str()), + }, + &chassis, + ); + + if hw_type.is_none() { + tracing::debug!( + bmc_address = ?addr, + bmc_vendor = ?vendor, + bmc_product = ?product, + "BMC reports no recognized hardware platform" + ); + } + + Ok(BmcPlatform { + hw_type, + vendor: vendor.and_then(non_empty), + product: product.and_then(non_empty), + }) +} + +/// Trims, and treats an all-whitespace value as unreported. BMCs pad these +/// fields, and a blank string would otherwise publish as a real answer. +fn non_empty(value: &str) -> Option { + let value = value.trim(); + (!value.is_empty()).then(|| value.to_string()) } #[cfg(test)] @@ -167,3 +314,128 @@ mod tests { assert!(select_primary_system(&[]).is_none()); } } + +/// End-to-end platform resolution against `bmc-mock`, which serves the real +/// service-root, system, and chassis payloads for each platform. +/// +/// The unit table in `hw-platform` pins the classification rules; this pins the +/// projection onto them -- that the fields are read from the resources the BMC +/// actually serves, and that the chassis collection is fetched at all. +#[cfg(test)] +mod bmc_mock_integration_tests { + use std::str::FromStr; + + use bmc_mock::test_support::{ + TestBmcHandle, dell_poweredge_r750_bmc, delta_powershelf_bmc, dgx_gb300_bmc, + generic_ami_bmc, generic_supermicro_bmc, hpe_proliant_dl380a_gen11_bmc, lenovo_gb300_bmc, + liteon_powershelf_bmc, nvidia_dgx_vr_host_bmc, nvidia_switch_nd5200_ld_bmc, + supermicro_gb300_bmc, wiwynn_gb200_bmc, + }; + use hw_platform::HwType; + use mac_address::MacAddress; + + use super::*; + + fn addr() -> BmcAddr { + BmcAddr { + ip: "10.0.0.1".parse().expect("valid ip"), + port: Some(443), + mac: MacAddress::from_str("00:11:22:33:44:55").expect("valid mac"), + } + } + + async fn platform_of(handle: TestBmcHandle) -> BmcPlatform { + let root = handle.service_root; + let addr = addr(); + // Mirrors production: an unreadable system collection is tolerated, and + // the power shelves rely on that -- they answer `/Systems` with 404. + let primary = primary_system_identity(&root, &addr).await; + + resolve_platform(&root, primary.as_ref().ok().and_then(Option::as_ref), &addr) + .await + .expect("platform resolves") + } + + async fn hw_type_of(handle: TestBmcHandle) -> Option { + platform_of(handle).await.hw_type + } + + #[tokio::test] + async fn resolves_host_platforms() { + assert_eq!( + hw_type_of(wiwynn_gb200_bmc().await).await, + Some(HwType::Gb200) + ); + assert_eq!( + hw_type_of(nvidia_dgx_vr_host_bmc().await).await, + Some(HwType::VeraRubin) + ); + assert_eq!( + hw_type_of(dell_poweredge_r750_bmc().await).await, + Some(HwType::Dell) + ); + assert_eq!( + hw_type_of(hpe_proliant_dl380a_gen11_bmc().await).await, + Some(HwType::Hpe) + ); + assert_eq!(hw_type_of(generic_ami_bmc().await).await, Some(HwType::Ami)); + assert_eq!( + hw_type_of(generic_supermicro_bmc().await).await, + Some(HwType::Supermicro) + ); + } + + // Every GB300 variant shares its service root with something else, so each + // one is proof the chassis collection was fetched and read. + #[tokio::test] + async fn resolves_gb300_variants_that_need_the_chassis() { + assert_eq!( + hw_type_of(dgx_gb300_bmc().await).await, + Some(HwType::DgxGb300), + "DGX GB300 shares GB200's service root exactly" + ); + assert_eq!( + hw_type_of(lenovo_gb300_bmc().await).await, + Some(HwType::LenovoGb300), + "Lenovo GB300 reports a generic AMI service root" + ); + assert_eq!( + hw_type_of(supermicro_gb300_bmc().await).await, + Some(HwType::SupermicroGb300), + ); + } + + // Neither shelf exposes a Systems collection, so resolution has to survive + // its absence and fall through to the chassis. + #[tokio::test] + async fn resolves_endpoints_without_a_system_collection() { + assert_eq!( + hw_type_of(liteon_powershelf_bmc().await).await, + Some(HwType::LiteonPowerShelf) + ); + assert_eq!( + hw_type_of(delta_powershelf_bmc().await).await, + Some(HwType::DeltaPowerShelf) + ); + } + + #[tokio::test] + async fn resolves_switch_bmc_platforms() { + assert_eq!( + hw_type_of(nvidia_switch_nd5200_ld_bmc().await).await, + Some(HwType::NvSwitch) + ); + } + + // The raw pair is what keeps an unclassified platform identifiable, so it + // has to be populated even where classification succeeds. + #[tokio::test] + async fn keeps_the_raw_vendor_and_product_beside_the_classification() { + let platform = platform_of(wiwynn_gb200_bmc().await).await; + + assert_eq!(platform.hw_type, Some(HwType::Gb200)); + assert_eq!(platform.vendor.as_deref(), Some("WIWYNN")); + assert_eq!(platform.product.as_deref(), Some("GB200 NVL")); + assert!(!platform.is_empty()); + } +} diff --git a/crates/health/src/discovery/iteration.rs b/crates/health/src/discovery/iteration.rs index 893a28370b..761dea5ef3 100644 --- a/crates/health/src/discovery/iteration.rs +++ b/crates/health/src/discovery/iteration.rs @@ -27,7 +27,7 @@ use super::cleanup::{ stop_ineligible_nmxc_collectors, stop_removed_bmc_collectors, stop_stale_switch_collectors, }; use super::context::{CollectorKind, DiscoveryLoopContext}; -use super::identity::ensure_primary_system_uuid; +use super::identity::ensure_endpoint_identity; use super::reachability::reconcile_reachability_collectors; use super::spawn::{spawn_collectors_for_endpoint, switch_supports_nmxc_subscription}; use crate::HealthError; @@ -90,17 +90,18 @@ pub async fn run_discovery_iteration( .cloned() .collect(); - // Resolve machine identity before collectors start when possible. Shared + // Resolve endpoint identity before collectors start when possible. Shared // write-once state propagates the result to running collectors and caches - // both present and absent UUIDs, preventing repeated successful BMC queries. + // both present and absent results, preventing repeated successful BMC + // queries. let identity_concurrency = ctx.discovery_config.discovery_concurrency.max(1); stream::iter(sharded_endpoints.iter().cloned()) .map(|endpoint| async move { - if let Err(error) = ensure_primary_system_uuid(&endpoint).await { + if let Err(error) = ensure_endpoint_identity(&endpoint).await { tracing::warn!( ?error, bmc_address = ?endpoint.addr, - "Could not resolve primary ComputerSystem UUID; continuing without it" + "Could not resolve endpoint identity; continuing without it" ); } }) diff --git a/crates/health/src/discovery/spawn.rs b/crates/health/src/discovery/spawn.rs index d7e4c804a5..e4b9173568 100644 --- a/crates/health/src/discovery/spawn.rs +++ b/crates/health/src/discovery/spawn.rs @@ -1392,6 +1392,7 @@ mod tests { .expect("constructor succeeds"), ); let endpoint = Arc::new(BmcEndpoint { + platform: Default::default(), addr, metadata: Some(switch_metadata_with_role( SwitchEndpointRole::Host, diff --git a/crates/health/src/endpoint/cluster.rs b/crates/health/src/endpoint/cluster.rs index 3289cb4e60..6cc257cd7c 100644 --- a/crates/health/src/endpoint/cluster.rs +++ b/crates/health/src/endpoint/cluster.rs @@ -32,7 +32,9 @@ use crate::bmc::{ BmcClient, BmcLatencyInstrumentation, FixedCredentialProvider, bmc_latency_endpoint_labels, }; use crate::config::ClusterEndpointSourceConfig; -use crate::endpoint::{BmcAddr, BmcCredentials, BmcEndpoint, BoxFuture, EndpointSource}; +use crate::endpoint::{ + BmcAddr, BmcCredentials, BmcEndpoint, BoxFuture, EndpointSource, SharedPlatform, +}; use crate::metrics::BmcLatencyMetrics; // ── Inventory file shape ────────────────────────────────────────────────────── @@ -484,6 +486,7 @@ fn build_endpoints( rack_id, labels: Default::default(), bmc, + platform: SharedPlatform::default(), })); } endpoints diff --git a/crates/health/src/endpoint/mod.rs b/crates/health/src/endpoint/mod.rs index b7a6096af5..3ec5b20a72 100644 --- a/crates/health/src/endpoint/mod.rs +++ b/crates/health/src/endpoint/mod.rs @@ -21,8 +21,8 @@ mod sources; pub use cluster::ClusterEndpointSource; pub use model::{ - BmcAddr, BmcCredentials, BmcEndpoint, EndpointMetadata, EndpointSource, MachineData, - PowerShelfData, SharedSystemUuid, SwitchData, SwitchEndpointRole, + BmcAddr, BmcCredentials, BmcEndpoint, BmcPlatform, EndpointMetadata, EndpointSource, + MachineData, PowerShelfData, SharedPlatform, SharedSystemUuid, SwitchData, SwitchEndpointRole, }; pub use sources::{CompositeEndpointSource, StaticEndpointSource}; @@ -66,6 +66,7 @@ pub(crate) mod test_support { .expect("fixed-credential BmcClient construction is infallible"), ); BmcEndpoint { + platform: Default::default(), addr, metadata, rack_id, diff --git a/crates/health/src/endpoint/model.rs b/crates/health/src/endpoint/model.rs index c641ab694b..c2f7a35dd8 100644 --- a/crates/health/src/endpoint/model.rs +++ b/crates/health/src/endpoint/model.rs @@ -26,6 +26,7 @@ use carbide_uuid::nvlink::NvLinkDomainId; use carbide_uuid::power_shelf::PowerShelfId; use carbide_uuid::rack::RackId; use carbide_uuid::switch::SwitchId; +use hw_platform::HwType; use mac_address::MacAddress; use tokio::sync::OnceCell; use url::Url; @@ -54,6 +55,14 @@ impl SharedSystemUuid { self.0.get().copied().flatten() } + /// True once resolution has run, whether or not it found a UUID. + /// + /// [`get`](Self::get) cannot answer this: it returns `None` both for "not + /// yet asked" and for "asked, and this BMC reports no UUID". + pub(crate) fn initialized(&self) -> bool { + self.0.initialized() + } + pub(crate) async fn get_or_try_init(&self, f: F) -> Result, E> where F: FnOnce() -> Fut, @@ -72,6 +81,82 @@ impl From> for SharedSystemUuid { } } +/// What a BMC reports about the hardware it manages. +/// +/// The raw `vendor` and `product` strings are kept beside the classified +/// `hw_type` rather than discarded once classification succeeds. A platform +/// that reaches the fleet before its classifier arm does still emits events, +/// and those events stay identifiable: `hw_type` is absent, but the raw pair +/// names the hardware, so triage works and a downstream rule can be written +/// against it without waiting for a hw-health release. With only `hw_type`, an +/// unrecognized platform would be indistinguishable from an unreachable BMC. +/// +/// A fully empty value is a real outcome -- the BMC answered and identified +/// nothing -- and is distinct from the unresolved state that [`SharedPlatform`] +/// represents with an uninitialized cell. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct BmcPlatform { + /// The classified platform, absent when no classifier arm matched. + pub hw_type: Option, + /// `ServiceRoot.Vendor`, trimmed; absent when empty or unreported. + pub vendor: Option, + /// `ServiceRoot.Product`, trimmed; absent when empty or unreported. + pub product: Option, +} + +impl BmcPlatform { + /// True when the BMC identified itself in no way at all. + /// + /// Publishing reads the three fields individually -- a platform that + /// reported only a vendor still publishes that vendor -- so this is not a + /// gate on emission. It names the "answered, and said nothing" outcome, + /// which is what distinguishes a resolved-but-empty cell from an + /// unresolved one. + pub fn is_empty(&self) -> bool { + self.hw_type.is_none() && self.vendor.is_none() && self.product.is_none() + } +} + +/// Shared, write-once hardware platform for one BMC endpoint. +/// +/// The same shape and the same reasoning as [`SharedSystemUuid`]: collectors +/// clone endpoint state when they start and a streaming collector keeps that +/// clone for the life of its stream, so a platform resolved after collector +/// startup only reaches emitted events through shared state. +/// +/// This lives on [`BmcEndpoint`] rather than in [`MachineData`] because it is +/// not machine-specific. Switch BMCs emit Redfish events too, and the taxonomy +/// has switch and power-shelf variants; hanging it off machine metadata would +/// silently drop every non-machine endpoint. +#[derive(Clone, Debug, Default)] +pub struct SharedPlatform(Arc>); + +impl PartialEq for SharedPlatform { + fn eq(&self, other: &Self) -> bool { + Arc::ptr_eq(&self.0, &other.0) + || matches!((self.0.get(), other.0.get()), (Some(left), Some(right)) if left == right) + } +} + +impl SharedPlatform { + pub fn get(&self) -> Option<&BmcPlatform> { + self.0.get() + } + + /// True once resolution has run, whether or not it identified anything. + pub(crate) fn initialized(&self) -> bool { + self.0.initialized() + } + + pub(crate) async fn get_or_try_init(&self, f: F) -> Result<&BmcPlatform, E> + where + F: FnOnce() -> Fut, + Fut: Future>, + { + self.0.get_or_try_init(f).await + } +} + #[derive(Clone)] pub struct BmcEndpoint { pub addr: BmcAddr, @@ -79,6 +164,13 @@ pub struct BmcEndpoint { pub rack_id: Option, pub labels: BTreeMap, pub bmc: Arc, + + /// Hardware platform reported by this BMC, resolved on demand by discovery. + /// + /// Shared write-once state so a platform resolved after collectors start + /// still reaches their emitted events, and so both a present and an absent + /// result are cached and the BMC is queried only once. + pub platform: SharedPlatform, } impl BmcEndpoint { @@ -107,6 +199,18 @@ impl BmcEndpoint { } } + /// Returns whether this endpoint speaks Redfish at all. + /// + /// Every endpoint kind does except the switch *host* side, which is reached + /// over NVUE/gNMI and exposes no Redfish service. Probing it would spend a + /// connection attempt and a warning per discovery pass to learn nothing. + pub(crate) fn supports_redfish(&self) -> bool { + !matches!( + self.metadata.as_ref(), + Some(EndpointMetadata::Switch(switch)) if switch.endpoint_role == SwitchEndpointRole::Host + ) + } + /// Returns whether this endpoint supports periodic Redfish log collection. pub(crate) fn supports_periodic_logs(&self) -> bool { match self.metadata.as_ref() { @@ -309,8 +413,8 @@ mod tests { use mac_address::MacAddress; use super::{ - BmcAddr, BmcCredentials, EndpointMetadata, MachineData, PowerShelfData, SharedSystemUuid, - SwitchData, SwitchEndpointRole, + BmcAddr, BmcCredentials, BmcPlatform, EndpointMetadata, MachineData, PowerShelfData, + SharedPlatform, SharedSystemUuid, SwitchData, SwitchEndpointRole, }; use crate::endpoint::test_support::{endpoint_with_creds, mac, test_endpoint}; @@ -428,6 +532,104 @@ mod tests { ); } + // Identity resolution probes Redfish, so it has to skip the one endpoint + // kind that has none. Every other kind emits Redfish events and needs a + // platform -- the taxonomy has switch and power-shelf variants. + #[test] + fn redfish_probing_covers_every_endpoint_kind_except_the_switch_host() { + let switch_bmc = SwitchData { + id: None, + serial: "switch".to_string(), + slot_number: Some(1), + tray_index: Some(2), + nvlink_domain_uuid: None, + endpoint_role: SwitchEndpointRole::Bmc, + is_primary: true, + nmxc_enabled: false, + nmxt_enabled: false, + }; + + let switch_host = SwitchData { + endpoint_role: SwitchEndpointRole::Host, + ..switch_bmc.clone() + }; + + check_values( + [ + Check { + scenario: "machine BMC speaks Redfish", + input: Some(EndpointMetadata::Machine(MachineData { + machine_id: None, + machine_serial: None, + system_uuid: SharedSystemUuid::default(), + slot_number: None, + tray_index: None, + nvlink_domain_uuid: None, + driver_version: None, + })), + expect: true, + }, + Check { + scenario: "switch BMC speaks Redfish", + input: Some(EndpointMetadata::Switch(switch_bmc)), + expect: true, + }, + // NVUE/gNMI only; there is no Redfish service to ask. + Check { + scenario: "switch host does not", + input: Some(EndpointMetadata::Switch(switch_host)), + expect: false, + }, + // Unlike periodic log collection, which power shelves opt out + // of, identity resolution covers them -- the taxonomy names + // both shelf vendors and only the chassis identifies them. + Check { + scenario: "power shelf speaks Redfish", + input: Some(EndpointMetadata::PowerShelf(PowerShelfData { + id: None, + serial: "power-shelf".to_string(), + })), + expect: true, + }, + Check { + scenario: "an endpoint without metadata is assumed to", + input: None, + expect: true, + }, + ], + |metadata| { + let mut endpoint = test_endpoint(mac("00:11:22:33:44:55")); + endpoint.metadata = metadata; + + endpoint.supports_redfish() + }, + ); + } + + #[tokio::test] + async fn shared_platform_caches_an_empty_result_across_clones() { + let state = SharedPlatform::default(); + let clone = state.clone(); + let query_count = AtomicUsize::new(0); + + for state in [&state, &clone] { + state + .get_or_try_init(|| async { + query_count.fetch_add(1, Ordering::SeqCst); + Ok::<_, Infallible>(BmcPlatform::default()) + }) + .await + .expect("infallible platform initialization"); + } + + // A BMC that identified nothing is still an answer; asking again every + // discovery pass would spend requests to re-learn it. + assert_eq!(query_count.load(Ordering::SeqCst), 1); + assert!(state.initialized()); + assert!(state.get().is_some_and(BmcPlatform::is_empty)); + assert!(clone.get().is_some_and(BmcPlatform::is_empty)); + } + #[tokio::test] async fn shared_system_uuid_caches_absent_result_across_clones() { let state = SharedSystemUuid::default(); diff --git a/crates/health/src/endpoint/sources.rs b/crates/health/src/endpoint/sources.rs index 2efc39c169..bb1d9203cd 100644 --- a/crates/health/src/endpoint/sources.rs +++ b/crates/health/src/endpoint/sources.rs @@ -33,7 +33,7 @@ use crate::bmc::{ use crate::config::{StaticBmcEndpoint, StaticSwitchEndpointRole}; use crate::endpoint::{ BmcAddr, BmcCredentials, BmcEndpoint, BoxFuture, EndpointMetadata, EndpointSource, MachineData, - PowerShelfData, SharedSystemUuid, SwitchData, SwitchEndpointRole, + PowerShelfData, SharedPlatform, SharedSystemUuid, SwitchData, SwitchEndpointRole, }; use crate::metrics::BmcLatencyMetrics; @@ -242,6 +242,7 @@ impl StaticEndpointSource { rack_id, labels: cfg.labels.clone(), bmc, + platform: SharedPlatform::default(), }; endpoints.push(Arc::new(endpoint)); } diff --git a/crates/health/src/otlp/convert.rs b/crates/health/src/otlp/convert.rs index a0c87f7ff8..597dfe8f0b 100644 --- a/crates/health/src/otlp/convert.rs +++ b/crates/health/src/otlp/convert.rs @@ -109,6 +109,25 @@ fn resource_attributes(context: &EventContext) -> Vec { if let Some(driver_version) = context.driver_version() { attrs.push(KeyValue::new("driver.version", driver_version.to_string())); } + // Hardware platform. Bare Redfish events are the reason this is here -- + // their message registry keys and vendor fault identifiers only mean + // something against a platform -- but it rides on the resource, so it also + // becomes a join key for every other signal from this endpoint. + // + // Each is omitted when unresolved rather than sent as "unknown": an absent + // attribute says the BMC did not answer, which is a different thing from a + // BMC that answered with a value nothing recognized. The raw vendor and + // product are published beside the classification so a platform that + // reaches the fleet before its classifier arm does is still identifiable. + if let Some(hw_platform) = context.hw_platform() { + attrs.push(KeyValue::new("hw.platform", hw_platform.as_str())); + } + if let Some(bmc_vendor) = context.bmc_vendor() { + attrs.push(KeyValue::new("bmc.vendor", bmc_vendor.to_string())); + } + if let Some(bmc_product) = context.bmc_product() { + attrs.push(KeyValue::new("bmc.product", bmc_product.to_string())); + } if let Some(component_type) = context.component_type() { attrs.push(KeyValue::new("component.type", component_type.to_string())); } @@ -492,12 +511,14 @@ mod tests { use carbide_uuid::rack::RackId; use carbide_uuid::switch::{SwitchId, SwitchIdSource, SwitchType}; use chrono::{TimeZone, Utc}; + use futures::FutureExt; + use hw_platform::HwType; use mac_address::MacAddress; use super::*; use crate::endpoint::{ - BmcAddr, EndpointMetadata, MachineData, PowerShelfData, SharedSystemUuid, SwitchData, - SwitchEndpointRole, + BmcAddr, BmcPlatform, EndpointMetadata, MachineData, PowerShelfData, SharedSystemUuid, + SwitchData, SwitchEndpointRole, }; use crate::otlp::common::{AnyValue as OtlpAnyValue, any_value}; use crate::sink::{ @@ -507,6 +528,7 @@ mod tests { fn test_context() -> EventContext { EventContext { + platform: Default::default(), endpoint_key: "42:9e:b1:bd:9d:dd".to_string(), addr: BmcAddr { ip: IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1)), @@ -601,6 +623,7 @@ mod tests { fn resource_attributes_include_machine_metadata_when_present() { let domain_uuid = NvLinkDomainId::nil(); let context = EventContext { + platform: Default::default(), endpoint_key: "42:9e:b1:bd:9d:dd".to_string(), addr: BmcAddr { ip: IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1)), @@ -651,6 +674,7 @@ mod tests { #[test] fn resource_attributes_omit_absent_optional_machine_metadata() { let context = EventContext { + platform: Default::default(), endpoint_key: "42:9e:b1:bd:9d:dd".to_string(), addr: BmcAddr { ip: IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1)), @@ -681,6 +705,72 @@ mod tests { assert_eq!(attr_value(&attrs, "nvlink.domain.uuid"), None); } + // The platform rides on the resource so it reaches every record from the + // endpoint. Bare Redfish events are what need it -- their registry keys and + // vendor fault ids only mean something against a platform -- and the + // downstream connector flattens resource and record attributes together, so + // stamping it once here is what those events read. + #[test] + fn resource_attributes_carry_the_hardware_platform_once_resolved() { + let context = test_context(); + context + .platform + .get_or_try_init(|| async { + Ok::<_, std::convert::Infallible>(BmcPlatform { + hw_type: Some(HwType::DgxGb300), + vendor: Some("NVIDIA".to_string()), + product: Some("GB BMC".to_string()), + }) + }) + .now_or_never() + .expect("initialization completes immediately") + .expect("infallible platform initialization"); + + let attrs = otlp_resource_attributes(&context); + + assert_eq!(attr_value(&attrs, "hw.platform"), Some("dgx_gb300")); + assert_eq!(attr_value(&attrs, "bmc.vendor"), Some("NVIDIA")); + assert_eq!(attr_value(&attrs, "bmc.product"), Some("GB BMC")); + } + + // Absent, not "unknown". An absent attribute says the BMC was not asked or + // did not answer; a literal "unknown" would be indistinguishable from a + // platform that answered with something nothing recognized, and the two + // call for different follow-up. + #[test] + fn resource_attributes_omit_the_platform_until_it_resolves() { + let attrs = otlp_resource_attributes(&test_context()); + + assert_eq!(attr_value(&attrs, "hw.platform"), None); + assert_eq!(attr_value(&attrs, "bmc.vendor"), None); + assert_eq!(attr_value(&attrs, "bmc.product"), None); + } + + // A platform that reaches the fleet before its classifier arm still has to + // be identifiable, or it is indistinguishable from an unreachable BMC. + #[test] + fn resource_attributes_publish_raw_identity_for_an_unclassified_platform() { + let context = test_context(); + context + .platform + .get_or_try_init(|| async { + Ok::<_, std::convert::Infallible>(BmcPlatform { + hw_type: None, + vendor: Some("Acme".to_string()), + product: Some("Anvil 9000".to_string()), + }) + }) + .now_or_never() + .expect("initialization completes immediately") + .expect("infallible platform initialization"); + + let attrs = otlp_resource_attributes(&context); + + assert_eq!(attr_value(&attrs, "hw.platform"), None); + assert_eq!(attr_value(&attrs, "bmc.vendor"), Some("Acme")); + assert_eq!(attr_value(&attrs, "bmc.product"), Some("Anvil 9000")); + } + #[test] fn resource_attributes_include_switch_placement_metadata_when_present() { let switch_id = test_switch_id("switch-a"); @@ -689,6 +779,7 @@ mod tests { let nvlink_domain_uuid_attr = nvlink_domain_uuid.to_string(); let context = EventContext { + platform: Default::default(), endpoint_key: "11:22:33:44:55:66".to_string(), addr: BmcAddr { ip: IpAddr::V4(Ipv4Addr::new(10, 0, 1, 1)), @@ -733,6 +824,7 @@ mod tests { let switch_id = test_switch_id("switch-host"); let switch_id_attr = switch_id.to_string(); let context = EventContext { + platform: Default::default(), endpoint_key: "11:22:33:44:55:66".to_string(), addr: BmcAddr { ip: IpAddr::V4(Ipv4Addr::new(10, 0, 1, 1)), @@ -789,6 +881,7 @@ mod tests { let nvlink_domain_uuid = NvLinkDomainId::new(); let nvlink_domain_uuid_attr = nvlink_domain_uuid.to_string(); let context = EventContext { + platform: Default::default(), endpoint_key: "22:33:44:55:66:77".to_string(), addr: BmcAddr { ip: IpAddr::V4(Ipv4Addr::new(10, 0, 2, 1)), @@ -852,6 +945,7 @@ mod tests { #[test] fn switch_bmc_log_resource_omits_unavailable_optional_metadata() { let context = EventContext { + platform: Default::default(), endpoint_key: "33:44:55:66:77:88".to_string(), addr: BmcAddr { ip: IpAddr::V4(Ipv4Addr::new(10, 0, 2, 2)), @@ -909,6 +1003,7 @@ mod tests { PowerShelfId::from_str("ps100ht038bg3qsho433vkg684heguv282qaggmrsh2ugn1qk096n2c6hcg") .expect("valid power shelf id"); let context = EventContext { + platform: Default::default(), endpoint_key: "33:44:55:66:77:88".to_string(), addr: BmcAddr { ip: IpAddr::V4(Ipv4Addr::new(10, 0, 3, 1)), @@ -1412,6 +1507,7 @@ mod tests { #[test] fn events_grouped_by_endpoint() { let ctx1 = EventContext { + platform: Default::default(), endpoint_key: "endpoint-a".to_string(), addr: BmcAddr { ip: IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1)), @@ -1424,6 +1520,7 @@ mod tests { labels: Default::default(), }; let ctx2 = EventContext { + platform: Default::default(), endpoint_key: "endpoint-b".to_string(), ..ctx1.clone() }; @@ -1457,10 +1554,12 @@ mod tests { fn metric_resources_are_grouped_by_endpoint_and_collector() { let base_ctx = test_context(); let rest_ctx = EventContext { + platform: Default::default(), collector_type: "nvue_rest", ..base_ctx.clone() }; let gnmi_ctx = EventContext { + platform: Default::default(), collector_type: "nvue_gnmi", ..base_ctx }; @@ -1523,6 +1622,7 @@ mod tests { let switch_id = test_switch_id("switch-nmxt"); let switch_id_attr = switch_id.to_string(); let context = EventContext { + platform: Default::default(), endpoint_key: "11:22:33:44:55:66".to_string(), addr: BmcAddr { ip: IpAddr::V4(Ipv4Addr::new(10, 0, 1, 1)), diff --git a/crates/health/src/processor/health_report.rs b/crates/health/src/processor/health_report.rs index ee64e9f360..a62e107c61 100644 --- a/crates/health/src/processor/health_report.rs +++ b/crates/health/src/processor/health_report.rs @@ -266,6 +266,7 @@ mod tests { fn test_context() -> EventContext { EventContext { + platform: Default::default(), endpoint_key: "42:9e:b1:bd:9d:dd".to_string(), addr: BmcAddr { ip: IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1)), diff --git a/crates/health/src/processor/intrusion_events.rs b/crates/health/src/processor/intrusion_events.rs index e474549d41..2cef08e7c9 100644 --- a/crates/health/src/processor/intrusion_events.rs +++ b/crates/health/src/processor/intrusion_events.rs @@ -185,6 +185,7 @@ mod tests { fn context() -> EventContext { EventContext { + platform: Default::default(), endpoint_key: "42:9e:b1:bd:9d:dd".to_string(), addr: BmcAddr { ip: IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1)), diff --git a/crates/health/src/processor/leak_events.rs b/crates/health/src/processor/leak_events.rs index 5d8a67ddbe..0ee9e0aa49 100644 --- a/crates/health/src/processor/leak_events.rs +++ b/crates/health/src/processor/leak_events.rs @@ -152,6 +152,7 @@ mod tests { fn context() -> EventContext { EventContext { + platform: Default::default(), endpoint_key: "42:9e:b1:bd:9d:dd".to_string(), addr: BmcAddr { ip: IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1)), diff --git a/crates/health/src/processor/mod.rs b/crates/health/src/processor/mod.rs index f0f2877f5d..bfc1f97196 100644 --- a/crates/health/src/processor/mod.rs +++ b/crates/health/src/processor/mod.rs @@ -184,6 +184,7 @@ mod tests { fn context() -> EventContext { EventContext { + platform: Default::default(), endpoint_key: "42:9e:b1:bd:9d:dd".to_string(), addr: BmcAddr { ip: IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1)), diff --git a/crates/health/src/processor/rack_leak.rs b/crates/health/src/processor/rack_leak.rs index f3be7d4082..ce2247ca77 100644 --- a/crates/health/src/processor/rack_leak.rs +++ b/crates/health/src/processor/rack_leak.rs @@ -174,6 +174,7 @@ mod tests { fn context_with_rack(mac: &str, rack: &str) -> EventContext { EventContext { + platform: Default::default(), endpoint_key: mac.to_string(), addr: BmcAddr { ip: IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1)), @@ -189,6 +190,7 @@ mod tests { fn context_without_rack(mac: &str) -> EventContext { EventContext { + platform: Default::default(), endpoint_key: mac.to_string(), addr: BmcAddr { ip: IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1)), diff --git a/crates/health/src/sink/events.rs b/crates/health/src/sink/events.rs index 27f5e70e78..269f7da0d9 100644 --- a/crates/health/src/sink/events.rs +++ b/crates/health/src/sink/events.rs @@ -28,10 +28,13 @@ use health_report::{ HealthAlertClassification, HealthProbeAlert, HealthProbeId, HealthProbeSuccess, HealthReport as CarbideHealthReport, HealthReportConversionError, }; +use hw_platform::HwType; use nv_redfish::resource::Health as BmcHealth; use serde::Serialize; -use crate::endpoint::{BmcAddr, BmcEndpoint, EndpointMetadata, MachineData, SwitchEndpointRole}; +use crate::endpoint::{ + BmcAddr, BmcEndpoint, EndpointMetadata, MachineData, SharedPlatform, SwitchEndpointRole, +}; use crate::metrics::MetricLabel; #[derive(Clone, Copy, Debug, Eq, PartialEq, carbide_instrument::LabelValue)] @@ -61,6 +64,10 @@ pub struct EventContext { pub metadata: Option, pub rack_id: Option, pub labels: BTreeMap, + + /// Shared with the endpoint, not copied out of it, so a platform resolved + /// after this context was cloned still reaches the events it stamps. + pub platform: SharedPlatform, } impl EventContext { @@ -72,6 +79,7 @@ impl EventContext { metadata: endpoint.metadata.clone(), rack_id: endpoint.rack_id.clone(), labels: endpoint.labels.clone(), + platform: endpoint.platform.clone(), } } @@ -116,6 +124,25 @@ impl EventContext { .and_then(|machine| machine.driver_version.as_deref()) } + /// Returns the classified hardware platform once discovery has resolved it. + pub fn hw_platform(&self) -> Option { + self.platform.get().and_then(|platform| platform.hw_type) + } + + /// Returns the BMC's reported `ServiceRoot.Vendor`. + pub fn bmc_vendor(&self) -> Option<&str> { + self.platform + .get() + .and_then(|platform| platform.vendor.as_deref()) + } + + /// Returns the BMC's reported `ServiceRoot.Product`. + pub fn bmc_product(&self) -> Option<&str> { + self.platform + .get() + .and_then(|platform| platform.product.as_deref()) + } + /// Returns the PHR component category for endpoints with typed metadata. pub fn component_type(&self) -> Option<&'static str> { self.metadata.as_ref().map(EndpointMetadata::component_type) @@ -616,7 +643,7 @@ mod tests { use mac_address::MacAddress; use super::*; - use crate::endpoint::{MachineData, PowerShelfData, SharedSystemUuid, SwitchData}; + use crate::endpoint::{BmcPlatform, MachineData, PowerShelfData, SharedSystemUuid, SwitchData}; #[derive(Clone, Copy)] enum ContextKind { @@ -750,6 +777,7 @@ mod tests { }; EventContext { + platform: Default::default(), endpoint_key: "00:11:22:33:44:55".to_string(), addr: addr(), collector_type: "unit-test", @@ -778,6 +806,55 @@ mod tests { assert_eq!(collector_context.system_uuid(), Some(expected)); } + // The whole reason the platform is shared rather than copied. A streaming + // collector builds its context once at startup and keeps it for the life of + // the stream, so a platform discovery resolves afterwards reaches emitted + // events only through this. + #[tokio::test] + async fn cloned_event_context_observes_later_platform_resolution() { + let context = context(ContextKind::Machine); + let collector_context = context.clone(); + + assert_eq!(collector_context.hw_platform(), None); + assert_eq!(collector_context.bmc_vendor(), None); + assert_eq!(collector_context.bmc_product(), None); + + context + .platform + .get_or_try_init(|| async { + Ok::<_, std::convert::Infallible>(BmcPlatform { + hw_type: Some(HwType::DgxGb300), + vendor: Some("NVIDIA".to_string()), + product: Some("GB BMC".to_string()), + }) + }) + .await + .expect("infallible platform initialization"); + + assert_eq!(collector_context.hw_platform(), Some(HwType::DgxGb300)); + assert_eq!(collector_context.bmc_vendor(), Some("NVIDIA")); + assert_eq!(collector_context.bmc_product(), Some("GB BMC")); + } + + // A BMC that answered and identified nothing is not the same as a BMC that + // was never asked, but both publish nothing -- so the accessors have to read + // the same either way. + #[tokio::test] + async fn a_resolved_but_empty_platform_reads_as_absent() { + let context = context(ContextKind::Switch); + + context + .platform + .get_or_try_init(|| async { Ok::<_, std::convert::Infallible>(BmcPlatform::default()) }) + .await + .expect("infallible platform initialization"); + + assert!(context.platform.get().is_some_and(BmcPlatform::is_empty)); + assert_eq!(context.hw_platform(), None); + assert_eq!(context.bmc_vendor(), None); + assert_eq!(context.bmc_product(), None); + } + fn summarize_context(context: EventContext) -> ContextSummary { ContextSummary { endpoint_key: context.endpoint_key().to_string(), diff --git a/crates/health/src/sink/health_report.rs b/crates/health/src/sink/health_report.rs index 99c63458dd..1d27221f1d 100644 --- a/crates/health/src/sink/health_report.rs +++ b/crates/health/src/sink/health_report.rs @@ -273,6 +273,7 @@ mod tests { fn machine_context(id: MachineId) -> EventContext { EventContext { + platform: Default::default(), endpoint_key: "00:00:00:00:00:01".to_string(), addr: BmcAddr { ip: "10.0.0.1".parse::().unwrap(), diff --git a/crates/health/src/sink/log_file.rs b/crates/health/src/sink/log_file.rs index 1c704f402f..efe648d5da 100644 --- a/crates/health/src/sink/log_file.rs +++ b/crates/health/src/sink/log_file.rs @@ -288,6 +288,7 @@ mod tests { /// Builds a base log context without endpoint metadata. fn test_context() -> EventContext { EventContext { + platform: Default::default(), endpoint_key: "aa:bb:cc:dd:ee:ff".to_string(), addr: BmcAddr { ip: "10.0.0.1".parse().expect("valid ip"), @@ -304,6 +305,7 @@ mod tests { /// Builds a log context with representative machine metadata. fn machine_context() -> EventContext { EventContext { + platform: Default::default(), labels: std::collections::BTreeMap::from([( "site".to_string(), "rno-dev7".to_string(), diff --git a/crates/health/src/sink/mod.rs b/crates/health/src/sink/mod.rs index 6e3b12e1fd..d42c328293 100644 --- a/crates/health/src/sink/mod.rs +++ b/crates/health/src/sink/mod.rs @@ -206,6 +206,7 @@ mod tests { CompositeDataSink::new(vec![sink_ok_1, sink_noop, sink_ok_2], metrics_manager); let context = EventContext { + platform: Default::default(), endpoint_key: "42:9e:b1:bd:9d:dd".to_string(), addr: BmcAddr { ip: "10.0.0.1".parse().expect("valid ip"), @@ -255,6 +256,7 @@ mod tests { ); let context = EventContext { + platform: Default::default(), endpoint_key: "42:9e:b1:bd:9d:dd".to_string(), addr: BmcAddr { ip: "10.0.0.1".parse().expect("valid ip"), @@ -357,6 +359,7 @@ mod tests { .expect("sink should initialize"); let context = EventContext { + platform: Default::default(), endpoint_key: "42:9e:b1:bd:9d:dd".to_string(), addr: BmcAddr { ip: "10.0.0.1".parse().expect("valid ip"), @@ -438,6 +441,7 @@ mod tests { .expect("sink should initialize"); let context = EventContext { + platform: Default::default(), endpoint_key: "42:9e:b1:bd:9d:dd".to_string(), addr: BmcAddr { ip: "10.0.0.1".parse().expect("valid ip"), @@ -498,6 +502,7 @@ mod tests { .expect("sink should initialize"); let context = EventContext { + platform: Default::default(), endpoint_key: "42:9e:b1:bd:9d:dd".to_string(), addr: BmcAddr { ip: "10.0.0.1".parse().expect("valid ip"), diff --git a/crates/health/src/sink/otlp.rs b/crates/health/src/sink/otlp.rs index 2fb1d13a10..cd45c5fbe8 100644 --- a/crates/health/src/sink/otlp.rs +++ b/crates/health/src/sink/otlp.rs @@ -368,6 +368,7 @@ mod tests { fn test_context() -> EventContext { EventContext { + platform: Default::default(), endpoint_key: "10.85.14.144".to_string(), addr: crate::endpoint::BmcAddr { ip: "10.85.14.144".parse().unwrap(), @@ -569,10 +570,12 @@ mod tests { fn metric_events_with_same_sample_identity_but_different_collector_are_separate_entries() { let sink = test_sink(); let rest_ctx = EventContext { + platform: Default::default(), collector_type: "nvue_rest", ..test_context() }; let gnmi_ctx = EventContext { + platform: Default::default(), collector_type: "nvue_gnmi", ..test_context() }; diff --git a/crates/health/src/sink/prometheus.rs b/crates/health/src/sink/prometheus.rs index 5f4cf54269..fd3ef6d9a3 100644 --- a/crates/health/src/sink/prometheus.rs +++ b/crates/health/src/sink/prometheus.rs @@ -276,6 +276,7 @@ mod tests { #[test] fn test_stream_static_labels_includes_machine_metadata() { let context = EventContext { + platform: Default::default(), endpoint_key: "42:9e:b1:bd:9d:dd".to_string(), addr: BmcAddr { ip: "10.0.0.1".parse().expect("valid ip"), @@ -333,6 +334,7 @@ mod tests { let nvlink_domain_uuid_label = nvlink_domain_uuid.to_string(); let context = EventContext { + platform: Default::default(), endpoint_key: "11:22:33:44:55:66".to_string(), addr: BmcAddr { ip: "10.0.1.1".parse().expect("valid ip"), diff --git a/crates/hw-platform/Cargo.toml b/crates/hw-platform/Cargo.toml new file mode 100644 index 0000000000..3b65422c66 --- /dev/null +++ b/crates/hw-platform/Cargo.toml @@ -0,0 +1,33 @@ +# +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +[package] +name = "hw-platform" +version = "0.1.0" +description = "Hardware platform taxonomy and Redfish-based platform classification" +edition.workspace = true +license.workspace = true +authors.workspace = true +repository.workspace = true + +[dependencies] +bmc-vendor = { path = "../bmc-vendor" } + +[dev-dependencies] +carbide-test-support = { path = "../test-support" } + +[lints] +workspace = true diff --git a/crates/hw-platform/src/lib.rs b/crates/hw-platform/src/lib.rs new file mode 100644 index 0000000000..b9cb74b4f4 --- /dev/null +++ b/crates/hw-platform/src/lib.rs @@ -0,0 +1,823 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +//! Hardware platform taxonomy, and the rules that resolve one from Redfish. +//! +//! [`HwType`] names a hardware platform; [`classify`] resolves one from the +//! Redfish fields that identify it. Both live here rather than in a consumer so +//! that every caller answers "which platform is this?" the same way. Two tables +//! that answer the same question drift silently -- nothing fails when a new +//! platform is added to one and not the other, and the divergence surfaces +//! later as mislabelled telemetry. +//! +//! [`classify`] takes plain string fields rather than Redfish resource types so +//! callers with very different amounts of the BMC already fetched can share it: +//! `bmc-explorer` projects its exploration types onto them, `carbide-health` +//! projects a `ServiceRoot`, a `ComputerSystem`, and a `Chassis` collection. + +use std::fmt; + +/// A hardware platform. +/// +/// This is coarser than a model number and finer than a vendor: it names a +/// class of machine whose Redfish surface, BIOS attributes, and event +/// vocabulary behave alike. Several variants share a [`bmc_vendor`] -- +/// `Gb200` and `DgxGb300` are both NVIDIA -- which is why platform and vendor +/// are separate axes. +/// +/// [`bmc_vendor`]: HwType::bmc_vendor +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum HwType { + Ami, + Bluefield, + Dell, + Gb200, + DgxGb300, + Hpe, + Lenovo, + LenovoAmi, + LenovoGb300, + SupermicroGb300, + Supermicro, + Viking, + LiteonPowerShelf, + DeltaPowerShelf, + NvSwitch, + VeraRubin, +} + +impl HwType { + /// The platform's stable wire name. + /// + /// Emitted as the `hw.platform` OTLP attribute, so downstream rules key on + /// these strings and they are an API. Spelled out rather than derived from + /// the variant name so a Rust-side rename cannot silently change what the + /// fleet reports; `wire_names_are_stable` pins every one. + pub const fn as_str(self) -> &'static str { + match self { + Self::Ami => "ami", + Self::Bluefield => "bluefield", + Self::Dell => "dell", + Self::Gb200 => "gb200", + Self::DgxGb300 => "dgx_gb300", + Self::Hpe => "hpe", + Self::Lenovo => "lenovo", + Self::LenovoAmi => "lenovo_ami", + Self::LenovoGb300 => "lenovo_gb300", + Self::SupermicroGb300 => "supermicro_gb300", + Self::Supermicro => "supermicro", + Self::Viking => "viking", + Self::LiteonPowerShelf => "liteon_power_shelf", + Self::DeltaPowerShelf => "delta_power_shelf", + Self::NvSwitch => "nv_switch", + Self::VeraRubin => "vera_rubin", + } + } + + pub const fn bmc_vendor(&self) -> Option { + match self { + Self::Ami => None, + Self::Bluefield => Some(bmc_vendor::BMCVendor::Nvidia), + Self::Dell => Some(bmc_vendor::BMCVendor::Dell), + Self::Gb200 => Some(bmc_vendor::BMCVendor::Nvidia), + // DGX GB300 uses the NVIDIA "GB BMC" (same BMC family as GB200). + Self::DgxGb300 => Some(bmc_vendor::BMCVendor::Nvidia), + Self::Hpe => Some(bmc_vendor::BMCVendor::Hpe), + Self::Lenovo => Some(bmc_vendor::BMCVendor::Lenovo), + Self::LenovoAmi => Some(bmc_vendor::BMCVendor::LenovoAMI), + Self::LenovoGb300 => Some(bmc_vendor::BMCVendor::LenovoAMI), + // SMC GB300 runs a Supermicro (OpenBMC) host BMC. + Self::SupermicroGb300 => Some(bmc_vendor::BMCVendor::Supermicro), + Self::LiteonPowerShelf => Some(bmc_vendor::BMCVendor::Liteon), + Self::DeltaPowerShelf => Some(bmc_vendor::BMCVendor::Delta), + Self::NvSwitch => Some(bmc_vendor::BMCVendor::Nvidia), + Self::Supermicro => Some(bmc_vendor::BMCVendor::Supermicro), + Self::Viking => Some(bmc_vendor::BMCVendor::Nvidia), + Self::VeraRubin => Some(bmc_vendor::BMCVendor::Nvidia), + } + } + + pub const fn infinite_boot_enabled_attr(&self) -> Option> { + match self { + Self::Ami => Some(BiosAttr::new_str("EndlessBoot", "Enabled")), + Self::Bluefield => None, + Self::Dell => Some(BiosAttr::new_str("BootSeqRetry", "Enabled")), + Self::Gb200 => Some(BiosAttr::new_str("EmbeddedUefiShell", "Disabled")), + // The DGX GB300 BIOS exposes EmbeddedUefiShell, but the value that means + // infinite-boot-enabled is not yet characterized on hardware (GB200's polarity + // is not assumed to carry over). Left None until confirmed on a tray. + // TODO(dgx-gb300): set the infinite-boot attribute from the DGX GB300 BIOS. + Self::DgxGb300 => None, + Self::Hpe => None, + Self::Lenovo => Some(BiosAttr::new_str("BootModes_InfiniteBootRetry", "Enabled")), + Self::LenovoAmi => Some(BiosAttr::new_str("EndlessBoot", "Enabled")), + Self::LenovoGb300 => Some(BiosAttr::new_int("LEM0003", 50)), + // TODO(smc): confirm the SMC GB300 infinite-boot BIOS attribute from the tray BIOS. + Self::SupermicroGb300 => None, + Self::LiteonPowerShelf => None, + Self::DeltaPowerShelf => None, + Self::NvSwitch => None, + Self::Supermicro => None, + Self::Viking => Some(BiosAttr::new_str("NvidiaInfiniteboot", "Enable")), + // Same EmbeddedUefiShell polarity as GB200 / libredfish NvidiaGBx00. + Self::VeraRubin => Some(BiosAttr::new_str("EmbeddedUefiShell", "Disabled")), + } + } +} + +impl fmt::Display for HwType { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.as_str()) + } +} + +#[derive(Clone, Copy)] +pub struct BiosAttr<'a> { + pub key: &'a str, + pub value: BiosAttrValue<'a>, +} + +impl BiosAttr<'_> { + pub const fn new_bool(key: &'static str, value: bool) -> BiosAttr<'static> { + BiosAttr { + key, + value: BiosAttrValue::Bool(value), + } + } + pub const fn new_str(key: &'static str, value: &'static str) -> BiosAttr<'static> { + BiosAttr { + key, + value: BiosAttrValue::Str(value), + } + } + pub const fn new_any_str( + key: &'static str, + value: &'static [&'static str], + ) -> BiosAttr<'static> { + BiosAttr { + key, + value: BiosAttrValue::AnyStr(value), + } + } + pub const fn new_int(key: &'static str, value: i64) -> BiosAttr<'static> { + BiosAttr { + key, + value: BiosAttrValue::Int(value), + } + } +} + +#[derive(Clone, Copy)] +pub enum BiosAttrValue<'a> { + Str(&'a str), + AnyStr(&'a [&'a str]), + Bool(bool), + Int(i64), +} + +impl fmt::Display for BiosAttrValue<'_> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + BiosAttrValue::Str(v) => v.fmt(f), + BiosAttrValue::Bool(v) => v.fmt(f), + BiosAttrValue::Int(v) => v.fmt(f), + BiosAttrValue::AnyStr(v) => { + write!(f, "any(")?; + for (index, value) in v.iter().enumerate() { + if index > 0 { + write!(f, ",")?; + } + write!(f, "{value}")?; + } + write!(f, ")") + } + } + } +} + +/// The service-level identity a BMC reports, as [`classify`] reads it. +/// +/// `vendor`, `product`, and `oem_id` come from the Redfish `ServiceRoot`; +/// `system_id` is the `Id` of the primary `ComputerSystem`, absent when the BMC +/// exposes no system collection (both power shelves do this). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct ServiceIdentity<'a> { + pub vendor: Option<&'a str>, + pub product: Option<&'a str>, + pub oem_id: Option<&'a str>, + pub system_id: Option<&'a str>, +} + +/// One `Chassis` member, as [`classify`] reads it. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct ChassisIdentity<'a> { + pub id: &'a str, + pub manufacturer: Option<&'a str>, + pub model: Option<&'a str>, +} + +/// Resolves the hardware platform, or `None` when nothing identifies it. +/// +/// `chassis` may be empty. Passing it empty is not free of consequence: the +/// GB300 platforms and both power shelves are *only* identifiable from chassis +/// data, and a GB300 tray with no chassis passed resolves as [`HwType::Gb200`] +/// rather than as `None`, because it shares GB200's service-root signature +/// exactly. Callers that cannot fetch the chassis collection should treat the +/// result as unreliable rather than as absent. +pub fn classify(service: ServiceIdentity<'_>, chassis: &[ChassisIdentity<'_>]) -> Option { + // GB300 is an NVIDIA HGX platform identity, recognized by the NVIDIA "NVIDIA GB300" + // GPU chassis (`is_gb300()`) independent of the host BMC vendor. Resolve it before the + // host-vendor match below so platform classification is not gated on the host ODM; the + // ODM only selects the ODM-specific variant. + if is_gb300(chassis) { + // Lenovo GB300: AMI host BMC + Lenovo host chassis. + if is_lenovo(chassis) { + return Some(HwType::LenovoGb300); + } + // DGX GB300: NVIDIA "GB BMC" host (same BMC family as GB200). Resolved here, ahead of + // the GB200 arm below, since it shares GB200's ServiceRoot signature -- the GB300 GPU + // chassis (`is_gb300()`) is what distinguishes it from a real GB200. + if service.vendor == Some("NVIDIA") && service.product == Some("GB BMC") { + return Some(HwType::DgxGb300); + } + // SMC GB300: Supermicro OpenBMC host. + if service.vendor == Some("Supermicro") { + return Some(HwType::SupermicroGb300); + } + } + + service + .vendor + .or_else(|| (service.oem_id == Some("Supermicro")).then_some("Supermicro")) + .and_then(|vendor_id| match vendor_id { + "AMI" if service.system_id == Some("DGX") => Some(HwType::Viking), + "AMI" => Some(HwType::Ami), + "Dell" => Some(HwType::Dell), + "Lenovo" if service.oem_id == Some("Ami") => Some(HwType::LenovoAmi), + "Lenovo" if service.oem_id != Some("Ami") => Some(HwType::Lenovo), + "Supermicro" => Some(HwType::Supermicro), + "HPE" => Some(HwType::Hpe), + "Nvidia" if is_bluefield_system_id(service.system_id) => Some(HwType::Bluefield), + "NVIDIA" if service.product == Some("VR NVL72") => Some(HwType::VeraRubin), + "WIWYNN" | "NVIDIA" + if matches!(service.product, Some("GB200 NVL") | Some("GB BMC")) => + { + Some(HwType::Gb200) + } + "NVIDIA" if service.product == Some("P3809") => Some(HwType::NvSwitch), + _ => None, + }) + .or_else(|| is_liteon_powershelf(chassis).then_some(HwType::LiteonPowerShelf)) + .or_else(|| is_delta_powershelf(chassis).then_some(HwType::DeltaPowerShelf)) +} + +/// True when classification for this vendor turns on the primary system's id. +/// +/// Only two vendors are ambiguous without it: `AMI` splits Viking from generic +/// AMI, and `Nvidia` splits BlueField from unclassified. Everything else reaches +/// the same answer whether or not a system id was available. +/// +/// This exists for callers that could not read the system collection at all -- +/// both power shelves permanently 404 that path. It tells "the id is irrelevant +/// here, classify anyway" apart from "classifying without it would produce a +/// confidently wrong answer, so do not". `classification_ignores_system_id_for_other_vendors` +/// pins it against [`classify`] so the two cannot disagree. +pub fn needs_system_id(vendor: Option<&str>) -> bool { + matches!(vendor, Some("AMI") | Some("Nvidia")) +} + +/// True when any chassis member is the NVIDIA GB300 GPU chassis. +/// +/// This is what separates a GB300 tray from a GB200 one; their service roots are +/// identical on the DGX variant. +fn is_gb300(chassis: &[ChassisIdentity<'_>]) -> bool { + chassis + .iter() + .any(|c| c.manufacturer == Some("NVIDIA") && c.model == Some("NVIDIA GB300")) +} + +fn is_lenovo(chassis: &[ChassisIdentity<'_>]) -> bool { + chassis.iter().any(|c| c.manufacturer == Some("Lenovo")) +} + +fn is_liteon_powershelf(chassis: &[ChassisIdentity<'_>]) -> bool { + chassis.iter().any(|c| { + c.id == "powershelf" + || (c.id == "chassis" + && c.manufacturer + .is_some_and(|mfg| mfg.to_lowercase().contains("lite-on"))) + }) +} + +/// Detects a Delta power shelf. Delta BMCs expose neither a `Vendor` in the +/// service root nor a `/redfish/v1/Systems` collection, so classification +/// relies on a Delta manufacturer on the power-shelf chassis (id "chassis" +/// or "powershelf"). The manufacturer gate is what distinguishes Delta from +/// the Lite-On power shelf, which shares the generic "powershelf" chassis id. +pub fn is_delta_powershelf(chassis: &[ChassisIdentity<'_>]) -> bool { + chassis + .iter() + .any(|c| is_delta_powershelf_chassis(c.id, c.manufacturer)) +} + +/// Delta power-shelf identity gate: a power-shelf chassis (id `chassis` or +/// `powershelf`) whose manufacturer identifies as Delta. This is what +/// distinguishes a Delta shelf from the Lite-On shelf, which shares the generic +/// `powershelf` chassis id but reports a different manufacturer. Split out so +/// the gate can be exercised in unit tests without a live BMC. +fn is_delta_powershelf_chassis(chassis_id: &str, manufacturer: Option<&str>) -> bool { + (chassis_id == "chassis" || chassis_id == "powershelf") + && manufacturer.is_some_and(|mfg| mfg.to_lowercase().contains("delta")) +} + +fn is_bluefield_system_id(system_id: Option<&str>) -> bool { + matches!(system_id, Some("Bluefield") | Some("BlueField_0")) +} + +#[cfg(test)] +mod tests { + use bmc_vendor::BMCVendor; + use carbide_test_support::{Check, check_values, value_scenarios}; + + use super::*; + + /// Every variant, so a new platform cannot be added without deciding its + /// wire name. + const ALL: [HwType; 16] = [ + HwType::Ami, + HwType::Bluefield, + HwType::Dell, + HwType::Gb200, + HwType::DgxGb300, + HwType::Hpe, + HwType::Lenovo, + HwType::LenovoAmi, + HwType::LenovoGb300, + HwType::SupermicroGb300, + HwType::Supermicro, + HwType::Viking, + HwType::LiteonPowerShelf, + HwType::DeltaPowerShelf, + HwType::NvSwitch, + HwType::VeraRubin, + ]; + + const GB300_CHASSIS: ChassisIdentity<'static> = ChassisIdentity { + id: "HGX_Chassis_0", + manufacturer: Some("NVIDIA"), + model: Some("NVIDIA GB300"), + }; + + const LENOVO_CHASSIS: ChassisIdentity<'static> = ChassisIdentity { + id: "Baseboard", + manufacturer: Some("Lenovo"), + model: None, + }; + + fn service<'a>(vendor: Option<&'a str>, product: Option<&'a str>) -> ServiceIdentity<'a> { + ServiceIdentity { + vendor, + product, + ..Default::default() + } + } + + // The wire names are published as `hw.platform`; a rename here is a fleet-visible + // API change, so it has to be a deliberate edit to this table rather than a + // side effect of renaming a Rust variant. + #[test] + fn wire_names_are_stable() { + value_scenarios!(run = |hardware_type: HwType| hardware_type.as_str(); + "hardware types render stable wire names" { + HwType::Ami => "ami", + HwType::Bluefield => "bluefield", + HwType::Dell => "dell", + HwType::Gb200 => "gb200", + HwType::DgxGb300 => "dgx_gb300", + HwType::Hpe => "hpe", + HwType::Lenovo => "lenovo", + HwType::LenovoAmi => "lenovo_ami", + HwType::LenovoGb300 => "lenovo_gb300", + HwType::SupermicroGb300 => "supermicro_gb300", + HwType::Supermicro => "supermicro", + HwType::Viking => "viking", + HwType::LiteonPowerShelf => "liteon_power_shelf", + HwType::DeltaPowerShelf => "delta_power_shelf", + HwType::NvSwitch => "nv_switch", + HwType::VeraRubin => "vera_rubin", + } + ); + } + + // Two platforms sharing a wire name would silently merge downstream. + #[test] + fn wire_names_are_unique() { + let mut names: Vec<&str> = ALL.iter().map(|hw| hw.as_str()).collect(); + names.sort_unstable(); + let unique = names.len(); + names.dedup(); + + assert_eq!(names.len(), unique, "duplicate hw.platform wire name"); + } + + #[test] + fn hw_type_bmc_vendor_maps_each_variant() { + value_scenarios!(run = |hardware_type: HwType| hardware_type.bmc_vendor(); + "generic AMI has no canonical vendor" { + HwType::Ami => None, + } + + "hardware types map to canonical vendors" { + HwType::Bluefield => Some(BMCVendor::Nvidia), + HwType::Dell => Some(BMCVendor::Dell), + HwType::Gb200 => Some(BMCVendor::Nvidia), + HwType::DgxGb300 => Some(BMCVendor::Nvidia), + HwType::Hpe => Some(BMCVendor::Hpe), + HwType::Lenovo => Some(BMCVendor::Lenovo), + HwType::LenovoAmi => Some(BMCVendor::LenovoAMI), + HwType::LenovoGb300 => Some(BMCVendor::LenovoAMI), + HwType::SupermicroGb300 => Some(BMCVendor::Supermicro), + HwType::Supermicro => Some(BMCVendor::Supermicro), + HwType::Viking => Some(BMCVendor::Nvidia), + HwType::LiteonPowerShelf => Some(BMCVendor::Liteon), + HwType::DeltaPowerShelf => Some(BMCVendor::Delta), + HwType::NvSwitch => Some(BMCVendor::Nvidia), + HwType::VeraRubin => Some(BMCVendor::Nvidia), + } + ); + } + + // The service-root signatures are bmc-mock's per-`HardwareType` ground truth + // (`crates/bmc-mock/src/machine_info.rs`), which is what the integration + // mocks actually serve. + #[test] + fn classifies_platforms_from_the_service_root_alone() { + check_values( + [ + Check { + scenario: "Dell iDRAC reports no product", + input: service(Some("Dell"), None), + expect: Some(HwType::Dell), + }, + Check { + scenario: "HPE iLO", + input: service(Some("HPE"), Some("ProLiant DL380a Gen11")), + expect: Some(HwType::Hpe), + }, + Check { + scenario: "Wiwynn ODM GB200 NVL tray reports its own vendor", + input: service(Some("WIWYNN"), Some("GB200 NVL")), + expect: Some(HwType::Gb200), + }, + Check { + scenario: "NVIDIA GB BMC without GB300 chassis is a real GB200", + input: service(Some("NVIDIA"), Some("GB BMC")), + expect: Some(HwType::Gb200), + }, + Check { + scenario: "Vera Rubin", + input: service(Some("NVIDIA"), Some("VR NVL72")), + expect: Some(HwType::VeraRubin), + }, + Check { + scenario: "NVLink switch", + input: service(Some("NVIDIA"), Some("P3809")), + expect: Some(HwType::NvSwitch), + }, + Check { + scenario: "generic AMI BMC", + input: service(Some("AMI"), Some("AMI Redfish Server")), + expect: Some(HwType::Ami), + }, + Check { + scenario: "generic Supermicro", + input: service(Some("Supermicro"), Some("Super Server")), + expect: Some(HwType::Supermicro), + }, + Check { + scenario: "unrecognised vendor", + input: service(Some("Acme"), Some("Anvil")), + expect: None, + }, + Check { + scenario: "no vendor at all", + input: service(None, None), + expect: None, + }, + ], + |identity| classify(identity, &[]), + ); + } + + // Viking and Bluefield share their vendor with platforms they must not be + // confused with; the primary system's id is the discriminator. + #[test] + fn classifies_platforms_needing_the_primary_system_id() { + check_values( + [ + Check { + scenario: "DGX H100 (Viking) is an AMI BMC with a DGX system", + input: Some("DGX"), + expect: Some(HwType::Viking), + }, + Check { + scenario: "any other AMI system is generic AMI", + input: Some("system"), + expect: Some(HwType::Ami), + }, + Check { + scenario: "no system collection is generic AMI", + input: None, + expect: Some(HwType::Ami), + }, + ], + |system_id| { + classify( + ServiceIdentity { + vendor: Some("AMI"), + product: Some("AMI Redfish Server"), + system_id, + ..Default::default() + }, + &[], + ) + }, + ); + + check_values( + [ + Check { + scenario: "BlueField-3 system id", + input: Some("Bluefield"), + expect: Some(HwType::Bluefield), + }, + Check { + scenario: "BlueField_0 system id", + input: Some("BlueField_0"), + expect: Some(HwType::Bluefield), + }, + Check { + scenario: "Nvidia vendor without a BlueField system is unclassified", + input: Some("system"), + expect: None, + }, + ], + |system_id| { + classify( + ServiceIdentity { + vendor: Some("Nvidia"), + product: Some("BlueField-3 DPU"), + system_id, + ..Default::default() + }, + &[], + ) + }, + ); + } + + // The case Tier-2 resolution gets wrong. DGX GB300 and GB200 share a service + // root byte for byte; only the GB300 GPU chassis tells them apart, and + // reporting a GB300 tray as `gb200` is acted on rather than investigated. + #[test] + fn gb300_is_distinguished_from_gb200_only_by_the_chassis() { + let dgx = service(Some("NVIDIA"), Some("GB BMC")); + + assert_eq!(classify(dgx, &[]), Some(HwType::Gb200)); + assert_eq!(classify(dgx, &[GB300_CHASSIS]), Some(HwType::DgxGb300)); + } + + #[test] + fn classifies_gb300_odm_variants_from_the_chassis() { + check_values( + [ + Check { + scenario: "Lenovo GB300: AMI host BMC, Lenovo host chassis", + input: ( + service(Some("AMI"), Some("AMI Redfish Server")), + &[GB300_CHASSIS, LENOVO_CHASSIS][..], + ), + expect: Some(HwType::LenovoGb300), + }, + Check { + scenario: "DGX GB300: NVIDIA GB BMC host", + input: ( + service(Some("NVIDIA"), Some("GB BMC")), + &[GB300_CHASSIS][..], + ), + expect: Some(HwType::DgxGb300), + }, + Check { + scenario: "SMC GB300: Supermicro OpenBMC host", + input: ( + service(Some("Supermicro"), Some("GB NVL")), + &[GB300_CHASSIS][..], + ), + expect: Some(HwType::SupermicroGb300), + }, + // The GB300 arm only selects the ODM variant. An unknown host + // vendor falls through to the vendor match rather than guessing. + Check { + scenario: "GB300 chassis behind an unrecognised host vendor", + input: (service(Some("Acme"), None), &[GB300_CHASSIS][..]), + expect: None, + }, + Check { + scenario: "a Supermicro without the GB300 chassis stays generic", + input: (service(Some("Supermicro"), Some("Super Server")), &[][..]), + expect: Some(HwType::Supermicro), + }, + ], + |(identity, chassis)| classify(identity, chassis), + ); + } + + // Both power shelves expose no vendor and no system collection, so the + // chassis is the only evidence there is. + #[test] + fn classifies_power_shelves_from_the_chassis_alone() { + check_values( + [ + Check { + scenario: "Lite-On, generic powershelf chassis id", + input: ChassisIdentity { + id: "powershelf", + manufacturer: Some("Lite-On"), + model: None, + }, + expect: Some(HwType::LiteonPowerShelf), + }, + Check { + scenario: "Lite-On, manufacturer on the chassis id", + input: ChassisIdentity { + id: "chassis", + manufacturer: Some("Lite-On Technology"), + model: None, + }, + expect: Some(HwType::LiteonPowerShelf), + }, + // Delta shares the generic "powershelf" id with Lite-On, and + // the Lite-On arm claims that id unconditionally -- so Delta is + // only reachable through the "chassis" id. + Check { + scenario: "Delta, manufacturer on the chassis id", + input: ChassisIdentity { + id: "chassis", + manufacturer: Some("Delta Energy Systems"), + model: None, + }, + expect: Some(HwType::DeltaPowerShelf), + }, + Check { + scenario: "a non-power-shelf chassis is not a shelf", + input: ChassisIdentity { + id: "Card1", + manufacturer: Some("Delta"), + model: None, + }, + expect: None, + }, + ], + |chassis| classify(ServiceIdentity::default(), &[chassis]), + ); + } + + // is_delta_powershelf_chassis gates Delta detection: a power-shelf chassis + // id ("chassis"/"powershelf") AND a Delta manufacturer. The manufacturer + // check is case-insensitive and substring-based, and is what separates a + // Delta shelf from a Lite-On shelf sharing the "powershelf" chassis id. + #[test] + fn is_delta_powershelf_chassis_gates_on_id_and_manufacturer() { + let cases: [(&str, Option<&str>, bool); 9] = [ + // Delta manufacturer on either accepted power-shelf chassis id. + ("chassis", Some("DELTA"), true), + ("powershelf", Some("Delta"), true), + // Case-insensitive, substring match on the manufacturer. + ("chassis", Some("delta electronics"), true), + ("powershelf", Some("Delta Energy Systems"), true), + // Right manufacturer but a non-power-shelf chassis id is ignored. + ("Card1", Some("DELTA"), false), + ("Baseboard", Some("delta"), false), + // Power-shelf chassis id but a different (or missing) manufacturer. + ("powershelf", Some("Lite-On"), false), + ("chassis", Some("NVIDIA"), false), + ("chassis", None, false), + ]; + for (id, mfg, expected) in cases { + assert_eq!( + is_delta_powershelf_chassis(id, mfg), + expected, + "id={id:?} manufacturer={mfg:?}" + ); + } + } + + // Lenovo's two variants differ only by the OEM identifier. + #[test] + fn lenovo_variants_split_on_the_oem_id() { + check_values( + [ + Check { + scenario: "Lenovo XCC", + input: None, + expect: Some(HwType::Lenovo), + }, + Check { + scenario: "Lenovo with an AMI OEM block", + input: Some("Ami"), + expect: Some(HwType::LenovoAmi), + }, + ], + |oem_id| { + classify( + ServiceIdentity { + vendor: Some("Lenovo"), + oem_id, + ..Default::default() + }, + &[], + ) + }, + ); + } + + // `needs_system_id` lets a caller that could not read the system collection + // decide whether classifying anyway is safe. That promise only holds if the + // vendors it clears really do classify identically either way. + #[test] + fn classification_ignores_system_id_for_other_vendors() { + const VENDORS: [Option<&str>; 9] = [ + Some("AMI"), + Some("Nvidia"), + Some("NVIDIA"), + Some("Dell"), + Some("HPE"), + Some("Lenovo"), + Some("Supermicro"), + Some("WIWYNN"), + None, + ]; + // Ids that change the answer for the vendors that do depend on one. + const SYSTEM_IDS: [&str; 4] = ["DGX", "Bluefield", "BlueField_0", "system"]; + const PRODUCTS: [Option<&str>; 5] = [ + None, + Some("GB BMC"), + Some("GB200 NVL"), + Some("VR NVL72"), + Some("P3809"), + ]; + + for vendor in VENDORS { + if needs_system_id(vendor) { + continue; + } + for product in PRODUCTS { + let without = classify(service(vendor, product), &[]); + for system_id in SYSTEM_IDS { + let with = classify( + ServiceIdentity { + vendor, + product, + system_id: Some(system_id), + ..Default::default() + }, + &[], + ); + assert_eq!( + with, without, + "vendor={vendor:?} product={product:?} system_id={system_id:?}", + ); + } + } + } + } + + // Some Supermicro BMCs name themselves only in the OEM block. + #[test] + fn supermicro_falls_back_to_the_oem_id_when_no_vendor_is_reported() { + assert_eq!( + classify( + ServiceIdentity { + oem_id: Some("Supermicro"), + ..Default::default() + }, + &[], + ), + Some(HwType::Supermicro), + ); + } +}