Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 11 additions & 10 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ tracing-appender = "0.2.4"
tracing-opentelemetry = "0.32.1"

# NV-Redfish
nv-redfish = { version = "0.10.0" }
nv-redfish = { version = "0.10.3" }

########
# MARK: - Pinned packages that we can't upgrade due to conflicts or just bugs
Expand Down
7 changes: 6 additions & 1 deletion crates/api-model/src/site_explorer/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1332,8 +1332,13 @@ pub struct EthernetInterface {
pub struct UefiDevicePath(String);

lazy_static! {
// Not anchored at start: GB300/Grace UEFI device paths prefix the PciRoot
// node with vendor/MMIO nodes, e.g.
// VenHw(<guid>)/MemoryMapped(0xB,...)/PciRoot(0x16)/Pci(0x0,0x0)/Pci(0x0,0x0)
// An `^PciRoot` anchor never matches those and aborts the whole exploration
// (`Could not match regex in PCI Device Path`). Match PciRoot wherever it appears.
static ref PCI_ROOT_REGEX: Regex =
Regex::new(r"^PciRoot\(([^)]*)\)").expect("must always compile");
Regex::new(r"PciRoot\(([^)]*)\)").expect("must always compile");
Comment on lines +1335 to +1341

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift

Add comprehensive test coverage for the regex parsing change.

This modification to PCI_ROOT_REGEX alters parsing behavior to accommodate GB300/Grace device paths with vendor/MMIO prefixes, yet no tests were added to verify correctness. According to the style guide, parsing functions are ideal candidates for table-driven tests using carbide-test-support.

The following scenarios should be covered:

  • GB300/Grace paths with vendor/MMIO prefixes: VenHw(...)/MemoryMapped(...)/PciRoot(0x16)/Pci(0x0,0x0)
  • Original paths without prefixes: PciRoot(0x8)/Pci(0x2,0xa)/Pci(0x0,0x0)
  • Paths with optional /MAC(...) suffix
  • Invalid paths: missing Pci nodes, malformed PciRoot, multiple PciRoot nodes

Without test coverage, we cannot verify the fix works as intended or prevent regressions in this critical hardware discovery path. As per path instructions and STYLE_GUIDE.md testing guidance, add table-driven tests before merging.

📋 Example test structure using carbide-test-support
#[cfg(test)]
mod tests {
    use super::*;
    use carbide_test_support::{scenarios, Outcome::*};

    #[test]
    fn parse_uefi_device_path() {
        scenarios!(UefiDevicePath::from_str:
            "GB300/Grace paths with vendor/MMIO prefix" {
                "VenHw(D3987D4B-971A-435F-8CAF-4967EB627241)/MemoryMapped(0xB,0x3FFBFE00000,0x3FFBFEFFFFF)/PciRoot(0x16)/Pci(0x0,0x0)" 
                    => Yields(UefiDevicePath("22.0.0".into())),
                "VenHw(...)/PciRoot(0x8)/Pci(0x2,0xa)/Pci(0x0,0x0)/MAC(...)" 
                    => Yields(UefiDevicePath("8.2.10.0.0".into())),
            }

            "original paths without prefix" {
                "PciRoot(0x8)/Pci(0x2,0xa)/Pci(0x0,0x0)" 
                    => Yields(UefiDevicePath("8.2.10.0.0".into())),
                "PciRoot(0x7)/Pci(0x0,0x0)" 
                    => Yields(UefiDevicePath("7.0.0".into())),
            }

            "invalid paths" {
                "PciRoot(0x8)" => Fails,  // missing Pci nodes
                "Pci(0x0,0x0)" => Fails,  // missing PciRoot
                "" => Fails,
            }
        );
    }
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/api-model/src/site_explorer/mod.rs` around lines 1335 - 1341, The
`PCI_ROOT_REGEX` modification in the site_explorer module lacks test coverage
for the parsing behavior change that now accommodates GB300/Grace device paths
with vendor/MMIO prefixes. Add table-driven tests using carbide-test-support in
a tests module that verify the following scenarios: GB300/Grace paths with VenHw
and MemoryMapped prefixes (e.g.,
`VenHw(...)/MemoryMapped(...)/PciRoot(0x16)/Pci(0x0,0x0)`), original unadorned
paths (e.g., `PciRoot(0x8)/Pci(0x2,0xa)/Pci(0x0,0x0)`), paths with optional MAC
suffixes, and invalid paths (missing Pci nodes, malformed PciRoot, no PciRoot
nodes). This ensures the regex change functions correctly and prevents
regressions in the hardware discovery path.

Sources: Coding guidelines, Path instructions

static ref PCI_NODE_REGEX: Regex = Regex::new(r"/Pci\(([^)]*)\)").expect("must always compile");
}

Expand Down
14 changes: 13 additions & 1 deletion crates/bmc-mock/src/test_support/axum_http_client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,9 @@ use axum::Router;
use axum::body::Body;
use axum::http::{HeaderMap, Method, Request, StatusCode};
use http_body_util::BodyExt;
use nv_redfish::bmc_http::{BmcCredentials, CacheableError, HttpClient};
use nv_redfish::bmc_http::{
BmcCredentials, CacheableError, HttpClient, RejectedUriReferenceError, RequestError,
};
use nv_redfish::core::upload::{MultipartUpdateRequest, UploadReader};
use nv_redfish::core::{BoxTryStream, ModificationResponse, ODataETag, SessionCreateResponse};
use serde::Serialize;
Expand All @@ -39,6 +41,7 @@ pub enum Error {
Json(serde_json::Error),
Http(axum::http::Error),
Cache(String),
RejectedUriReference(String),
NotSupported(&'static str),
}

Expand All @@ -51,13 +54,22 @@ impl fmt::Display for Error {
Self::Json(err) => write!(f, "json error: {err}"),
Self::Http(err) => write!(f, "http build error: {err}"),
Self::Cache(reason) => write!(f, "cache error: {reason}"),
Self::RejectedUriReference(reason) => {
write!(f, "rejected URI reference: {reason}")
}
Self::NotSupported(what) => write!(f, "not supported in test client: {what}"),
}
}
}

impl std::error::Error for Error {}

impl RequestError for Error {
fn rejected_uri_reference(error: RejectedUriReferenceError) -> Self {
Self::RejectedUriReference(error.reason)
}
}

impl CacheableError for Error {
fn is_cached(&self) -> bool {
matches!(
Expand Down
42 changes: 37 additions & 5 deletions crates/site-explorer/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1188,21 +1188,53 @@ impl SiteExplorer {
}
}

// A DPU can show up as a chassis network adapter instead of a PCIe
// device on some BMCs; fall back to those only if the PCIe scan found none.
// A DPU can show up as a chassis instead of a PCIe device on some
// BMCs; fall back to the chassis inventory only if the PCIe scan
// found none.
if dpu_exploration.expected_managed_total() == 0 {
for chassis in ep.report.chassis.iter() {
for network_adapter in chassis.network_adapters.iter() {
// Some BMCs (e.g. the AMI/Lenovo GB300 host BMC) report the
// BlueField as the chassis object itself -- model, part_number
// and serial_number live on the chassis, while its nested
// network adapter carries an empty serial. Match on the
// chassis identity in that case; otherwise fall back to the
// chassis's network adapters (other vendors put the DPU
// serial there). Matching only one of the two per chassis
// keeps a single DPU from being counted twice.
let chassis_is_bluefield = chassis
.part_number
.as_deref()
.map(str::trim)
.is_some_and(is_bluefield_model);
let chassis_has_serial = chassis
.serial_number
.as_deref()
.map(str::trim)
.is_some_and(|serial| !serial.is_empty());
if chassis_is_bluefield && chassis_has_serial {
self.record_host_dpu_device(
network_adapter.part_number.as_deref(),
network_adapter.serial_number.as_deref(),
chassis.part_number.as_deref(),
chassis.serial_number.as_deref(),
&dpu_sn_to_endpoint,
host_dpu_mode,
&ep,
&mut dpu_exploration,
metrics,
)
.await;
} else {
for network_adapter in chassis.network_adapters.iter() {
Comment on lines +1214 to +1226

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Do not skip adapter serials unless the chassis serial can actually pair.

Line 1214 selects the chassis path for any non-empty BlueField chassis serial, but record_host_dpu_device only pairs if that serial exists in dpu_sn_to_endpoint. If the chassis serial is an enclosure/card serial and the nested adapter has the DPU system serial, this skips the adapter and prevents pairing.

Proposed fix
-                    let chassis_has_serial = chassis
+                    let chassis_serial = chassis
                         .serial_number
                         .as_deref()
                         .map(str::trim)
-                        .is_some_and(|serial| !serial.is_empty());
-                    if chassis_is_bluefield && chassis_has_serial {
+                        .filter(|serial| !serial.is_empty());
+                    let chassis_serial_matches_discovered_dpu = chassis_serial
+                        .is_some_and(|serial| dpu_sn_to_endpoint.contains_key(serial));
+                    if chassis_is_bluefield && chassis_serial_matches_discovered_dpu {
                         self.record_host_dpu_device(
                             chassis.part_number.as_deref(),
-                            chassis.serial_number.as_deref(),
+                            chassis_serial,
                             &dpu_sn_to_endpoint,
                             host_dpu_mode,
                             &ep,
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/site-explorer/src/lib.rs` around lines 1214 - 1226, The condition on
line 1214 that checks `chassis_is_bluefield && chassis_has_serial` is too
permissive and causes adapter serials to be skipped even when the chassis serial
cannot actually pair. Modify the condition to only take the chassis path
(calling record_host_dpu_device and skipping the adapter iteration in the else
block) if the chassis serial actually exists as a key in the dpu_sn_to_endpoint
map. Currently it skips adapters whenever chassis_has_serial is true, but it
should only skip them when the chassis serial can actually be paired based on
what exists in dpu_sn_to_endpoint. Check if chassis.serial_number.as_deref()
exists in the dpu_sn_to_endpoint map before deciding to take the chassis path
and skip the adapter iteration.

self.record_host_dpu_device(
network_adapter.part_number.as_deref(),
network_adapter.serial_number.as_deref(),
&dpu_sn_to_endpoint,
host_dpu_mode,
&ep,
&mut dpu_exploration,
metrics,
)
.await;
}
}
}
}
Expand Down
39 changes: 25 additions & 14 deletions crates/site-explorer/src/redfish.rs
Original file line number Diff line number Diff line change
Expand Up @@ -232,22 +232,33 @@ impl RedfishClient {
.map_err(|err| redact_password(err, curr_password.as_str()))
.map_err(map_redfish_error)?;
}
// Vikings and Lenovo GB300s. GB300s are detected as AMI at this
// point (vendor isn't refined to LenovoGB300 until later), but both
// rotate via the same admin account, so handle them together.
// Vikings and Lenovo GB300s (both still detected as AMI here).
// Resolve the admin account by username, and fall back to the conventional
// id "2" only when reads are blocked by `PasswordChangeRequired` (Viking factory state).
// Any other error propagates.
//
// https://docs.nvidia.com/dgx/dgxh100-user-guide/redfish-api-supp.html
RedfishVendor::AMI | RedfishVendor::LenovoGB300 => {
/*
https://docs.nvidia.com/dgx/dgxh100-user-guide/redfish-api-supp.html

You should set the password after the first boot. The following curl command changes the password for the admin user.
curl -k -u <bmc-user>:<password> --request PATCH 'https://<bmc-ip-address>/redfish/v1/AccountService/Accounts/2' --header 'If-Match: *' --header 'Content-Type: application/json' --data-raw '{ "Password" : "<password>" }'
*/
client
.change_password_by_id("2", new_password.as_str())
match client
.change_password(curr_user.as_str(), new_password.as_str())
.await
.map_err(|err| redact_password(err, new_password.as_str()))
.map_err(|err| redact_password(err, curr_password.as_str()))
.map_err(map_redfish_error)?;
{
Ok(()) => {}
Err(libredfish::RedfishError::PasswordChangeRequired) => {
client
.change_password_by_id("2", new_password.as_str())
.await
Comment on lines +247 to +250

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Do not apply the AMI /Accounts/2 fallback to LenovoGB300.

This branch includes RedfishVendor::LenovoGB300, but the fallback still PATCHes account ID "2" when account reads are blocked. The PR objective says GB300 admin accounts are at ID 1 or 4, so this can either preserve the GB300 404 failure or rotate the wrong account while the caller proceeds with curr_user/new_password.

Split the PasswordChangeRequired fallback by vendor and use only a validated GB300-specific fallback path, or return a clear error for GB300 when the account cannot be resolved safely.

As per path instructions, BMC-facing code should account for “credential handling” and “vendor differences.”

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/site-explorer/src/redfish.rs` around lines 247 - 250, The
PasswordChangeRequired error handler is applying an AMI-specific fallback using
hardcoded account ID "2" to all vendors, including LenovoGB300, but GB300 admin
accounts are at IDs "1" or "4", not "2". In the
Err(libredfish::RedfishError::PasswordChangeRequired) branch, add
vendor-specific logic to check whether the client is LenovoGB300 or another
vendor before applying the account ID "2" fallback. For LenovoGB300, either
return a clear error indicating the account cannot be resolved safely, or use
GB300-specific account IDs if a validated fallback path exists. This ensures the
fallback only applies to vendors where account "2" is appropriate and prevents
attempts to change the wrong account.

Source: Path instructions

.map_err(|err| redact_password(err, new_password.as_str()))
.map_err(|err| redact_password(err, curr_password.as_str()))
.map_err(map_redfish_error)?;
}
Err(err) => {
return Err(map_redfish_error(redact_password(
redact_password(err, new_password.as_str()),
curr_password.as_str(),
)));
}
}
}
RedfishVendor::LenovoAMI
| RedfishVendor::Supermicro
Expand Down
116 changes: 114 additions & 2 deletions crates/site-explorer/tests/site_explorer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -35,8 +35,8 @@ use model::machine::machine_search_config::MachineSearchConfig;
use model::machine::{LoadSnapshotOptions, Machine};
use model::metadata::Metadata;
use model::site_explorer::{
ComputerSystem, EndpointExplorationError, EndpointExplorationReport, EndpointType, ExploredDpu,
ExploredManagedHost, NicMode, PreingestionState, UefiDevicePath,
Chassis, ComputerSystem, EndpointExplorationError, EndpointExplorationReport, EndpointType,
ExploredDpu, ExploredManagedHost, NetworkAdapter, NicMode, PreingestionState, UefiDevicePath,
};
use model::test_support::{DpuConfig, ManagedHostConfig};
use rpc::forge::GetSiteExplorationRequest;
Expand Down Expand Up @@ -2891,6 +2891,118 @@ async fn test_site_explorer_enforces_nic_mode_on_fallback_serial_match(
Ok(())
}

/// Some host BMCs (e.g. the AMI/Lenovo GB300 `HG635N_V2`) report the BlueField
/// as the chassis object itself -- model, part_number and serial_number live on
/// the chassis, while its nested network adapter carries an empty serial -- and
/// don't enumerate the DPU over PCIe at all. The host<->DPU serial match must
/// therefore consider the chassis identity, not just `chassis.network_adapters[]`.
///
/// Here the operator declares NO `fallback_dpu_serial_numbers` and the default
/// `DpuMode`, so the only thing that can pair the host with its DPU is the
/// chassis-reported serial. The host must pair (and not fall through to the
/// zero-DPU path).
#[sqlx_test]
async fn test_site_explorer_pairs_dpu_from_chassis_serial(
pool: PgPool,
) -> Result<(), Box<dyn std::error::Error>> {
use model::expected_machine::{ExpectedMachine, ExpectedMachineData};
use model::site_explorer::NicMode;

let env = Env::new(pool).await;

const CHASSIS_DPU_SERIAL: &str = "chassis-reported-dpu-serial";
// The DPU's BMC reports DPU mode; the host BMC carries no DPU PCIe device,
// only the BlueField chassis below, so the chassis serial is the only link.
let dpu_config = DpuConfig {
nic_mode: Some(NicMode::Dpu),
serial: CHASSIS_DPU_SERIAL.to_string(),
..DpuConfig::default()
};
// A host with no DPU configs: its report carries no BlueField PCIe device or
// network adapter, so the only BlueField is the chassis we push below. (A
// configured DPU would inject a phantom BlueField into the host's PCIe scan,
// making `expected_managed_total() != 0` and skipping the chassis fallback.)
let mock_host = ManagedHostConfig::zero_dpu();
let host_bmc_mac = mock_host.bmc_mac_address;

let mut txn = env.pool.begin().await?;
db::expected_machine::create(
&mut txn,
ExpectedMachine {
id: None,
bmc_mac_address: host_bmc_mac,
data: ExpectedMachineData {
bmc_username: "ADMIN".to_string(),
bmc_password: "PASS".to_string(),
serial_number: "EM-GB300-CHASSIS-SERIAL".to_string(),
metadata: model::metadata::Metadata::new_with_default_name(),
..Default::default()
},
},
)
.await?;
txn.commit().await?;

let mut host_report: EndpointExplorationReport = mock_host.into();
host_report.chassis.push(Chassis {
id: "Riser_Slot1_BlueField_3_SmartNIC_Main_Card".to_string(),
manufacturer: Some("Nvidia".to_string()),
model: Some("BlueField-3 SmartNIC Main Card".to_string()),
part_number: Some("900-9D3B6-00CN-PA0".to_string()),
serial_number: Some(CHASSIS_DPU_SERIAL.to_string()),
network_adapters: vec![NetworkAdapter {
id: "Riser_Slot1_BlueField_3_SmartNIC_Main_Card".to_string(),
serial_number: Some(String::new()),
..Default::default()
}],
..Default::default()
});

let mut host_bmc = env.new_machine(&host_bmc_mac.to_string(), "SomeVendor");
let mut dpu_bmc = env.new_machine(&dpu_config.bmc_mac_address.to_string(), "NVIDIA/BF/BMC");
host_bmc.discover_dhcp(env.api()).await?;
dpu_bmc.discover_dhcp(env.api()).await?;

let explorer_config = SiteExplorerConfig {
enabled: Arc::new(true.into()),
retained_boot_interface_window: None,
explorations_per_run: 10,
concurrent_explorations: 1,
run_interval: std::time::Duration::from_secs(1),
create_machines: Arc::new(true.into()),
..Default::default()
};
let explorer = env.test_site_explorer(explorer_config);
explorer.insert_endpoint_results(vec![
(dpu_bmc.ip.parse().unwrap(), Ok(dpu_config.clone().into())),
(host_bmc.ip.parse().unwrap(), Ok(host_report)),
]);

// First iteration: initial endpoint exploration.
explorer.run_single_iteration().await.unwrap();
let mut txn = env.pool.begin().await?;
for ip in [host_bmc.ip.parse()?, dpu_bmc.ip.parse()?] {
db::explored_endpoints::set_preingestion_complete(ip, &mut txn).await?;
}
txn.commit().await?;
// Second iteration: per-host matching pairs the DPU off the chassis serial.
explorer.run_single_iteration().await.unwrap();

let explored_managed_hosts = db::explored_managed_host::find_all(&env.pool).await?;
assert_eq!(
explored_managed_hosts.len(),
1,
"GB300 host should pair via its chassis-reported BlueField serial"
);
assert_eq!(
explored_managed_hosts[0].dpus.len(),
1,
"the chassis-matched DPU should be attached to the host"
);

Ok(())
}

/// A managed host's DPU-facing `machine_interface` is created (via DHCP) with
/// just a MAC and no `boot_interface_id`. The exploration that ingests the host
/// then backfills the vendor-specific Redfish interface id onto that row, matched
Expand Down
Loading