Skip to content
Closed
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: 19 additions & 2 deletions crates/admin-cli/src/browse/nmxc/args.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
* limitations under the License.
*/

use carbide_uuid::rack::RackId;
use clap::{Parser, ValueEnum};
use rpc::forge as forgerpc;

Expand Down Expand Up @@ -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

Expand All @@ -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<String>,

#[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<RackId>,

#[clap(long, value_enum, help = "NMX-C browse operation to run")]
pub operation: NmxcOperationArg,
Expand Down
3 changes: 2 additions & 1 deletion crates/admin-cli/src/browse/nmxc/cmd.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
})
Expand Down
1 change: 1 addition & 0 deletions crates/admin-cli/src/machine/nvlink_info/cmd.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
})
Expand Down
98 changes: 89 additions & 9 deletions crates/api-core/src/handlers/nmxc_browse.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -151,34 +152,44 @@ pub(crate) async fn nmxc_browse(

let request = request.into_inner();

let rack_id = request.rack_id.as_ref();
let group_type = resolve_group_type(&request.chassis_serial, rack_id)?;
let chassis_serial = request.chassis_serial.trim();
if chassis_serial.is_empty() {
return Err(CarbideError::MissingArgument("chassis_serial").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)?;

Expand Down Expand Up @@ -239,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<ManagedHostGroupType, CarbideError> {
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),
}
);
}
}
31 changes: 30 additions & 1 deletion crates/api-web/src/nmxc_browser.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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,
Expand All @@ -48,6 +51,7 @@ struct Header {
#[derive(Debug, Deserialize)]
pub struct QueryParams {
chassis_serial: Option<String>,
rack_id: Option<String>,
operation: Option<String>,
gpu_uid: Option<String>,
}
Expand All @@ -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(),
Expand All @@ -83,17 +89,38 @@ pub async fn query(
let op = browse_operation_from_query(&browser.operation);
let gpu_uid = browser.gpu_uid.trim().parse::<u64>().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);

if !can_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::<RackId>() {
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,
}))
Expand Down Expand Up @@ -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":"<script>alert('xss')</script>"}}"#,
u128::MAX
Expand Down
32 changes: 28 additions & 4 deletions crates/api-web/templates/nmxc_browser.html
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,24 @@
<h1>NMX-C Browser</h1>

<p>
Choose an operation and chassis serial.
Choose an operation and an endpoint — either a chassis serial or a rack ID.
See the <a href="https://docs.nvidia.com/networking/display/nmxcv11/grpc+api+documentation">NMX-C gRPC API documentation</a> for details.
</p>

<form id="nmxc_console" method="GET" action="/admin/nmxc-browser">
<div>
<label for="chassis_serial_input">Chassis serial:</label>
<input type=text size="40" id="chassis_serial_input" name="chassis_serial" value="{{ chassis_serial }}" />
<label for="endpoint_value_input">Endpoint:</label>
<div style="display: flex; gap: 0.5rem; align-items: center;">
<select id="endpoint_type_select" style="width: auto; flex-shrink: 0;" onchange="nmxcUpdateEndpoint(this)">
<option value="chassis_serial" {% if rack_id.is_empty() %}selected{% endif %}>Chassis serial</option>
<option value="rack_id" {% if !rack_id.is_empty() %}selected{% endif %}>Rack ID</option>
</select>
<input type=text id="endpoint_value_input"
name="{% if !rack_id.is_empty() %}rack_id{% else %}chassis_serial{% endif %}"
value="{% if !rack_id.is_empty() %}{{ rack_id }}{% else %}{{ chassis_serial }}{% endif %}"
placeholder="{% if !rack_id.is_empty() %}e.g. ipp6-b03-gb-nvl-124-mini2{% else %}e.g. 1234567890{% endif %}"
style="flex: 1;" />
</div>
</div>
<div>
<label for="operation_input">Operation:</label>
Expand All @@ -34,7 +44,7 @@ <h1>NMX-C Browser</h1>
<input type="submit" value="Run query">
</form>

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

<h3>Metadata</h3>
<table class="detailsview">
Expand Down Expand Up @@ -82,3 +92,17 @@ <h3>Response</h3>
{% endif %}

{% endblock %}

{% block script %}
<script>
function nmxcUpdateEndpoint(select) {
var input = document.getElementById('endpoint_value_input');
input.name = select.value;
input.value = '';
input.placeholder = select.value === 'rack_id'
? 'e.g. ipp6-b03-gb-nvl-124-mini2'
: 'e.g. 1234567890';
input.focus();
}
</script>
{% endblock %}
4 changes: 4 additions & 0 deletions crates/rpc/proto/forge.proto
Original file line number Diff line number Diff line change
Expand Up @@ -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`; 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`; at least one of `rack_id` or `chassis_serial` must be set.
optional common.RackId rack_id = 5;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

message NmxcBrowseResponse {
Expand Down
Loading