From de7bc75d0ffa10bf35efbb927cbc7844244347ff Mon Sep 17 00:00:00 2001 From: Roopesh Tamma Date: Fri, 31 Jul 2026 13:41:25 -0700 Subject: [PATCH 1/2] fix(nmxc-browser): resolved NMX-C endpoint by rack ID as well. Add rack_id as an alternative to chassis_serial in NmxcBrowseRequest. When rack_id is provided the endpoint is resolved from the rack's ready control-plane switch NVOS IP via resolve_nmx_c_endpoint_url, removing the requirement for a nvlink_nmxc_endpoints row. Signed-off-by: Roopesh Tamma --- crates/admin-cli/src/browse/nmxc/args.rs | 21 +++++++++- crates/admin-cli/src/browse/nmxc/cmd.rs | 3 +- .../admin-cli/src/machine/nvlink_info/cmd.rs | 1 + crates/api-core/src/handlers/nmxc_browse.rs | 41 +++++++++++++++---- crates/api-web/src/nmxc_browser.rs | 31 +++++++++++++- crates/api-web/templates/nmxc_browser.html | 32 +++++++++++++-- crates/rpc/proto/forge.proto | 4 ++ 7 files changed, 117 insertions(+), 16 deletions(-) diff --git a/crates/admin-cli/src/browse/nmxc/args.rs b/crates/admin-cli/src/browse/nmxc/args.rs index 977490cb6d..86ec1bb961 100644 --- a/crates/admin-cli/src/browse/nmxc/args.rs +++ b/crates/admin-cli/src/browse/nmxc/args.rs @@ -15,6 +15,7 @@ * limitations under the License. */ +use carbide_uuid::rack::RackId; use clap::{Parser, ValueEnum}; use rpc::forge as forgerpc; @@ -59,6 +60,9 @@ EXAMPLES: List the GPUs on a chassis via NMX-C: $ nico-admin-cli browse nmxc --chassis-serial 1234567890 --operation gpu-info-list +List the GPUs in a rack via NMX-C: + $ nico-admin-cli browse nmxc --rack-id ipp6-b03-gb-nvl-124-mini2 --operation gpu-info-list + List the compute nodes on a chassis: $ nico-admin-cli browse nmxc --chassis-serial 1234567890 --operation compute-node-info-list @@ -76,8 +80,21 @@ Get NMX-C domain properties: ")] pub struct Args { - #[clap(long, help = "Chassis serial number")] - pub chassis_serial: String, + #[clap( + long, + help = "Chassis serial number (mutually exclusive with --rack-id)", + conflicts_with = "rack_id", + required_unless_present = "rack_id" + )] + pub chassis_serial: Option, + + #[clap( + long, + help = "Rack ID; resolves the NMX-C endpoint from the rack's ready control-plane switch (mutually exclusive with --chassis-serial)", + conflicts_with = "chassis_serial", + required_unless_present = "chassis_serial" + )] + pub rack_id: Option, #[clap(long, value_enum, help = "NMX-C browse operation to run")] pub operation: NmxcOperationArg, diff --git a/crates/admin-cli/src/browse/nmxc/cmd.rs b/crates/admin-cli/src/browse/nmxc/cmd.rs index 90aae117e2..7ec8a99c2e 100644 --- a/crates/admin-cli/src/browse/nmxc/cmd.rs +++ b/crates/admin-cli/src/browse/nmxc/cmd.rs @@ -25,7 +25,8 @@ pub async fn browse(args: Args, api_client: &ApiClient) -> CarbideCliResult<()> let resp = api_client .0 .nmxc_browse(forgerpc::NmxcBrowseRequest { - chassis_serial: args.chassis_serial, + chassis_serial: args.chassis_serial.unwrap_or_default(), + rack_id: args.rack_id, operation: forgerpc::NmxcBrowseOperation::from(args.operation) as i32, gpu_uid: args.gpu_uid, }) diff --git a/crates/admin-cli/src/machine/nvlink_info/cmd.rs b/crates/admin-cli/src/machine/nvlink_info/cmd.rs index 1cba34a3e5..02071ae443 100644 --- a/crates/admin-cli/src/machine/nvlink_info/cmd.rs +++ b/crates/admin-cli/src/machine/nvlink_info/cmd.rs @@ -128,6 +128,7 @@ pub async fn handle_nvlink_info_populate( .0 .nmxc_browse(forgerpc::NmxcBrowseRequest { chassis_serial: serial_number.clone(), + rack_id: None, operation: forgerpc::NmxcBrowseOperation::GpuInfoList as i32, gpu_uid: 0, }) diff --git a/crates/api-core/src/handlers/nmxc_browse.rs b/crates/api-core/src/handlers/nmxc_browse.rs index 7dc8451b89..02b09fe502 100644 --- a/crates/api-core/src/handlers/nmxc_browse.rs +++ b/crates/api-core/src/handlers/nmxc_browse.rs @@ -18,6 +18,7 @@ use std::collections::HashMap; use ::rpc::forge as rpc; +use carbide_nvlink_manager::nmx_c_endpoint::{ManagedHostGroupType, resolve_nmx_c_endpoint_url}; use libnmxc::nmxc_model::{ GetComputeNodeInfoListRequest, GetGpuInfoListRequest, GetPartitionInfoListRequest, GetSwitchNodeInfoListRequest, GpuAttr, @@ -152,33 +153,57 @@ pub(crate) async fn nmxc_browse( let request = request.into_inner(); let chassis_serial = request.chassis_serial.trim(); - if chassis_serial.is_empty() { - return Err(CarbideError::MissingArgument("chassis_serial").into()); + let rack_id = request.rack_id.as_ref(); + + if rack_id.is_some() && !chassis_serial.is_empty() { + return Err(CarbideError::InvalidArgument( + "chassis_serial and rack_id are mutually exclusive".to_string(), + ) + .into()); } + let group_type = if rack_id.is_some() { + ManagedHostGroupType::Rack + } else if !chassis_serial.is_empty() { + ManagedHostGroupType::Chassis + } else { + return Err(CarbideError::MissingArgument("chassis_serial or rack_id").into()); + }; + let op = rpc::NmxcBrowseOperation::try_from(request.operation) .unwrap_or(rpc::NmxcBrowseOperation::Unspecified); if let Some(nvlink_config) = api.runtime_config.nvlink_config.as_ref() && nvlink_config.enabled { - let endpoint_row = db::nvlink_nmxc_endpoints::find_by_chassis_serial( - &api.database_connection, - chassis_serial, + let mut db = api.db_reader(); + let endpoint_url = resolve_nmx_c_endpoint_url( + &mut db, + group_type, + rack_id, + if chassis_serial.is_empty() { + None + } else { + Some(chassis_serial) + }, + nvlink_config, ) .await?; - let Some(row) = endpoint_row else { + let Some(url) = endpoint_url else { + let endpoint_id = rack_id + .map(|r| r.to_string()) + .unwrap_or_else(|| chassis_serial.to_string()); return Err(CarbideError::NotFoundError { kind: "nvlink_nmxc_endpoint", - id: chassis_serial.to_string(), + id: endpoint_id, } .into()); }; let mut nmxc = api .nmxc_client_pool - .create_client(Endpoint::new(row.endpoint.clone()).map_err(CarbideError::from)?) + .create_client(Endpoint::new(url).map_err(CarbideError::from)?) .await .map_err(CarbideError::from)?; diff --git a/crates/api-web/src/nmxc_browser.rs b/crates/api-web/src/nmxc_browser.rs index da9d65a1c8..1d45866317 100644 --- a/crates/api-web/src/nmxc_browser.rs +++ b/crates/api-web/src/nmxc_browser.rs @@ -21,6 +21,7 @@ use askama::Template; use axum::extract::{Query as AxumQuery, State as AxumState}; use axum::response::{Html, IntoResponse, Response}; use carbide_api_core::Api; +use carbide_uuid::rack::RackId; use hyper::http::StatusCode; use rpc::forge::forge_server::Forge; use serde::Deserialize; @@ -31,8 +32,10 @@ use super::{Base, filters}; #[template(path = "nmxc_browser.html")] struct NmxcBrowser { chassis_serial: String, + rack_id: String, operation: String, gpu_uid: String, + query_was_executed: bool, response: String, error: String, status_code: u16, @@ -48,6 +51,7 @@ struct Header { #[derive(Debug, Deserialize)] pub struct QueryParams { chassis_serial: Option, + rack_id: Option, operation: Option, gpu_uid: Option, } @@ -71,8 +75,10 @@ pub async fn query( ) -> Response { let mut browser = NmxcBrowser { chassis_serial: query.chassis_serial.clone().unwrap_or_default(), + rack_id: query.rack_id.clone().unwrap_or_default(), operation: query.operation.clone().unwrap_or_default(), gpu_uid: query.gpu_uid.clone().unwrap_or_default(), + query_was_executed: false, response: String::new(), response_headers: Vec::new(), error: String::new(), @@ -83,7 +89,8 @@ pub async fn query( let op = browse_operation_from_query(&browser.operation); let gpu_uid = browser.gpu_uid.trim().parse::().unwrap_or(0); let needs_gpu_uid = op == rpc::forge::NmxcBrowseOperation::GpuInfo as i32; - let can_query = !browser.chassis_serial.is_empty() + let has_endpoint = !browser.chassis_serial.is_empty() || !browser.rack_id.is_empty(); + let can_query = has_endpoint && op != rpc::forge::NmxcBrowseOperation::Unspecified as i32 && (!needs_gpu_uid || gpu_uid != 0); @@ -91,9 +98,29 @@ pub async fn query( return (StatusCode::OK, Html(browser.render().unwrap())).into_response(); } + if !browser.chassis_serial.is_empty() && !browser.rack_id.is_empty() { + browser.error = + "Provide either a chassis serial or a rack ID, not both.".to_string(); + return (StatusCode::OK, Html(browser.render().unwrap())).into_response(); + } + + let parsed_rack_id = if browser.rack_id.is_empty() { + None + } else { + match browser.rack_id.trim().parse::() { + Ok(id) => Some(id), + Err(_) => { + browser.error = format!("Invalid rack ID: {}", browser.rack_id); + return (StatusCode::OK, Html(browser.render().unwrap())).into_response(); + } + } + }; + + browser.query_was_executed = true; let response = match state .nmxc_browse(tonic::Request::new(rpc::forge::NmxcBrowseRequest { chassis_serial: browser.chassis_serial.clone(), + rack_id: parsed_rack_id, operation: op, gpu_uid, })) @@ -136,8 +163,10 @@ mod tests { fn rendered_response_preserves_large_integers_and_escapes_html() { let browser = NmxcBrowser { chassis_serial: "chassis".to_string(), + rack_id: String::new(), operation: "gpu_info".to_string(), gpu_uid: u64::MAX.to_string(), + query_was_executed: true, response: format!( r#"{{"gpu_uid":{},"message":""}}"#, u128::MAX diff --git a/crates/api-web/templates/nmxc_browser.html b/crates/api-web/templates/nmxc_browser.html index fd81a6a030..58e709d73e 100644 --- a/crates/api-web/templates/nmxc_browser.html +++ b/crates/api-web/templates/nmxc_browser.html @@ -6,14 +6,24 @@

NMX-C Browser

-Choose an operation and chassis serial. +Choose an operation and an endpoint — either a chassis serial or a rack ID. See the NMX-C gRPC API documentation for details.

- - + +
+ + +
@@ -34,7 +44,7 @@

NMX-C Browser

-{% if !chassis_serial.is_empty() && !operation.is_empty() && (operation != "gpu_info" || !gpu_uid.is_empty()) %} +{% if query_was_executed %}

Metadata

@@ -82,3 +92,17 @@

Response

{% endif %} {% endblock %} + +{% block script %} + +{% endblock %} diff --git a/crates/rpc/proto/forge.proto b/crates/rpc/proto/forge.proto index fa156cff67..ad3c936ad5 100644 --- a/crates/rpc/proto/forge.proto +++ b/crates/rpc/proto/forge.proto @@ -8035,10 +8035,14 @@ message NmxcBrowseRequest { reserved 1; reserved "path"; // Chassis serial used to resolve the NMX-C gRPC endpoint URL from `nvlink_nmxc_endpoints`. + // Mutually exclusive with `rack_id`. string chassis_serial = 2; NmxcBrowseOperation operation = 3; // Required when `operation` is `NMXC_BROWSE_OPERATION_GPU_INFO`; ignored for `NMXC_BROWSE_OPERATION_GPU_INFO_LIST`. uint64 gpu_uid = 4; + // Rack identifier; used to resolve the NMX-C endpoint from the rack's ready control-plane switch + // NVOS IP. Mutually exclusive with `chassis_serial`. + optional common.RackId rack_id = 5; } message NmxcBrowseResponse { From 3b31da750373dc10c38305dd336cf821308906ec Mon Sep 17 00:00:00 2001 From: Roopesh Tamma Date: Sat, 1 Aug 2026 03:09:41 -0700 Subject: [PATCH 2/2] chore: code review fixes Signed-off-by: Roopesh Tamma --- crates/api-core/src/handlers/nmxc_browse.rs | 87 +++++++++++++++++---- crates/rpc/proto/forge.proto | 4 +- 2 files changed, 73 insertions(+), 18 deletions(-) diff --git a/crates/api-core/src/handlers/nmxc_browse.rs b/crates/api-core/src/handlers/nmxc_browse.rs index 02b09fe502..01ac44c243 100644 --- a/crates/api-core/src/handlers/nmxc_browse.rs +++ b/crates/api-core/src/handlers/nmxc_browse.rs @@ -152,23 +152,9 @@ pub(crate) async fn nmxc_browse( let request = request.into_inner(); - let chassis_serial = request.chassis_serial.trim(); let rack_id = request.rack_id.as_ref(); - - if rack_id.is_some() && !chassis_serial.is_empty() { - return Err(CarbideError::InvalidArgument( - "chassis_serial and rack_id are mutually exclusive".to_string(), - ) - .into()); - } - - let group_type = if rack_id.is_some() { - ManagedHostGroupType::Rack - } else if !chassis_serial.is_empty() { - ManagedHostGroupType::Chassis - } else { - return Err(CarbideError::MissingArgument("chassis_serial or rack_id").into()); - }; + let group_type = resolve_group_type(&request.chassis_serial, rack_id)?; + let chassis_serial = request.chassis_serial.trim(); let op = rpc::NmxcBrowseOperation::try_from(request.operation) .unwrap_or(rpc::NmxcBrowseOperation::Unspecified); @@ -264,3 +250,72 @@ pub(crate) async fn nmxc_browse( Err(CarbideError::internal("nvlink config not enabled".to_string()).into()) } } + +/// Determines the `ManagedHostGroupType` from a browse request's selector fields. +/// +/// `chassis_serial` is trimmed before inspection, so a whitespace-only value is +/// treated as absent. The two selectors are mutually exclusive: providing both +/// is an `InvalidArgument` error; providing neither is a `MissingArgument` error. +fn resolve_group_type( + chassis_serial: &str, + rack_id: Option<&carbide_uuid::rack::RackId>, +) -> Result { + let chassis_serial = chassis_serial.trim(); + if rack_id.is_some() && !chassis_serial.is_empty() { + return Err(CarbideError::InvalidArgument( + "chassis_serial and rack_id are mutually exclusive".to_string(), + )); + } + if rack_id.is_some() { + Ok(ManagedHostGroupType::Rack) + } else if !chassis_serial.is_empty() { + Ok(ManagedHostGroupType::Chassis) + } else { + Err(CarbideError::MissingArgument("chassis_serial or rack_id")) + } +} + +#[cfg(test)] +mod tests { + use carbide_test_support::Outcome::{FailsWith, Yields}; + use carbide_test_support::scenarios; + use carbide_uuid::rack::RackId; + + use super::*; + + /// Mapped error discriminant so table rows can assert which validation rule fired. + #[derive(Debug, PartialEq)] + enum SelectionError { + BothProvided, + NeitherProvided, + } + + #[test] + fn selector_validation_resolves_group_type_or_rejects_invalid_inputs() { + scenarios!(run = |(chassis_serial, rack_id_str): (&str, Option<&str>)| { + let rack_id = rack_id_str.map(RackId::new); + resolve_group_type(chassis_serial, rack_id.as_ref()).map_err(|e| match e { + CarbideError::InvalidArgument(_) => SelectionError::BothProvided, + _ => SelectionError::NeitherProvided, + }) + }; + "rack-only" { + ("", Some("rack-a")) => Yields(ManagedHostGroupType::Rack), + } + + "chassis-only" { + ("SN-123", None) => Yields(ManagedHostGroupType::Chassis), + (" SN-123 ", None) => Yields(ManagedHostGroupType::Chassis), + } + + "both provided" { + ("SN-123", Some("rack-a")) => FailsWith(SelectionError::BothProvided), + } + + "neither provided" { + ("", None) => FailsWith(SelectionError::NeitherProvided), + (" ", None) => FailsWith(SelectionError::NeitherProvided), + } + ); + } +} diff --git a/crates/rpc/proto/forge.proto b/crates/rpc/proto/forge.proto index ad3c936ad5..689e9c20fe 100644 --- a/crates/rpc/proto/forge.proto +++ b/crates/rpc/proto/forge.proto @@ -8035,13 +8035,13 @@ message NmxcBrowseRequest { reserved 1; reserved "path"; // Chassis serial used to resolve the NMX-C gRPC endpoint URL from `nvlink_nmxc_endpoints`. - // Mutually exclusive with `rack_id`. + // Mutually exclusive with `rack_id`; at least one of `chassis_serial` or `rack_id` must be set. string chassis_serial = 2; NmxcBrowseOperation operation = 3; // Required when `operation` is `NMXC_BROWSE_OPERATION_GPU_INFO`; ignored for `NMXC_BROWSE_OPERATION_GPU_INFO_LIST`. uint64 gpu_uid = 4; // Rack identifier; used to resolve the NMX-C endpoint from the rack's ready control-plane switch - // NVOS IP. Mutually exclusive with `chassis_serial`. + // NVOS IP. Mutually exclusive with `chassis_serial`; at least one of `rack_id` or `chassis_serial` must be set. optional common.RackId rack_id = 5; }